summaryrefslogtreecommitdiff
path: root/lib/python
diff options
context:
space:
mode:
Diffstat (limited to 'lib/python')
-rw-r--r--lib/python/kle2xy.py126
-rw-r--r--lib/python/qmk/__init__.py0
-rw-r--r--lib/python/qmk/build_targets.py279
-rw-r--r--lib/python/qmk/c_parse.py324
-rw-r--r--lib/python/qmk/cli/__init__.py288
-rwxr-xr-xlib/python/qmk/cli/bux.py49
-rw-r--r--lib/python/qmk/cli/c2json.py73
-rwxr-xr-xlib/python/qmk/cli/cd.py47
-rw-r--r--lib/python/qmk/cli/chibios/__init__.py0
-rw-r--r--lib/python/qmk/cli/chibios/confmigrate.py162
-rw-r--r--lib/python/qmk/cli/ci/__init__.py0
-rw-r--r--lib/python/qmk/cli/ci/validate_aliases.py58
-rw-r--r--lib/python/qmk/cli/clean.py14
-rwxr-xr-xlib/python/qmk/cli/compile.py83
-rw-r--r--lib/python/qmk/cli/docs.py30
-rwxr-xr-xlib/python/qmk/cli/doctor/__init__.py5
-rw-r--r--lib/python/qmk/cli/doctor/check.py285
-rw-r--r--lib/python/qmk/cli/doctor/linux.py155
-rw-r--r--lib/python/qmk/cli/doctor/macos.py13
-rwxr-xr-xlib/python/qmk/cli/doctor/main.py240
-rw-r--r--lib/python/qmk/cli/doctor/windows.py20
-rw-r--r--lib/python/qmk/cli/find.py32
-rw-r--r--lib/python/qmk/cli/flash.py116
-rw-r--r--lib/python/qmk/cli/format/__init__.py0
-rw-r--r--lib/python/qmk/cli/format/c.py138
-rwxr-xr-xlib/python/qmk/cli/format/json.py118
-rwxr-xr-xlib/python/qmk/cli/format/python.py70
-rw-r--r--lib/python/qmk/cli/format/text.py57
-rw-r--r--lib/python/qmk/cli/generate/__init__.py0
-rwxr-xr-xlib/python/qmk/cli/generate/api.py204
-rw-r--r--lib/python/qmk/cli/generate/autocorrect_data.py291
-rw-r--r--lib/python/qmk/cli/generate/community_modules.py341
-rw-r--r--lib/python/qmk/cli/generate/compilation_database.py9
-rwxr-xr-xlib/python/qmk/cli/generate/config_h.py212
-rwxr-xr-xlib/python/qmk/cli/generate/develop_pr_list.py130
-rw-r--r--lib/python/qmk/cli/generate/dfu_header.py50
-rw-r--r--lib/python/qmk/cli/generate/docs.py34
-rwxr-xr-xlib/python/qmk/cli/generate/info_json.py93
-rwxr-xr-xlib/python/qmk/cli/generate/keyboard_c.py274
-rwxr-xr-xlib/python/qmk/cli/generate/keyboard_h.py130
-rw-r--r--lib/python/qmk/cli/generate/keycodes.py182
-rw-r--r--lib/python/qmk/cli/generate/keymap_h.py51
-rwxr-xr-xlib/python/qmk/cli/generate/make_dependencies.py56
-rw-r--r--lib/python/qmk/cli/generate/rgb_breathe_table.py78
-rwxr-xr-xlib/python/qmk/cli/generate/rules_mk.py117
-rw-r--r--lib/python/qmk/cli/generate/version_h.py62
-rw-r--r--lib/python/qmk/cli/git/__init__.py0
-rw-r--r--lib/python/qmk/cli/git/submodule.py53
-rwxr-xr-xlib/python/qmk/cli/hello.py13
-rw-r--r--lib/python/qmk/cli/import/__init__.py0
-rw-r--r--lib/python/qmk/cli/import/kbfirmware.py25
-rw-r--r--lib/python/qmk/cli/import/keyboard.py23
-rw-r--r--lib/python/qmk/cli/import/keymap.py23
-rwxr-xr-xlib/python/qmk/cli/info.py277
-rwxr-xr-xlib/python/qmk/cli/json2c.py28
-rwxr-xr-xlib/python/qmk/cli/kle2json.py59
-rw-r--r--lib/python/qmk/cli/license_check.py131
-rw-r--r--lib/python/qmk/cli/lint.py406
-rw-r--r--lib/python/qmk/cli/list/__init__.py0
-rw-r--r--lib/python/qmk/cli/list/keyboards.py13
-rw-r--r--lib/python/qmk/cli/list/keymaps.py22
-rw-r--r--lib/python/qmk/cli/list/layouts.py23
-rwxr-xr-xlib/python/qmk/cli/mass_compile.py151
-rw-r--r--lib/python/qmk/cli/migrate.py84
-rw-r--r--lib/python/qmk/cli/new/__init__.py0
-rw-r--r--lib/python/qmk/cli/new/keyboard.py273
-rwxr-xr-xlib/python/qmk/cli/new/keymap.py150
-rw-r--r--lib/python/qmk/cli/painter/__init__.py2
-rw-r--r--lib/python/qmk/cli/painter/convert_graphics.py77
-rw-r--r--lib/python/qmk/cli/painter/make_font.py79
-rw-r--r--lib/python/qmk/cli/pytest.py18
-rw-r--r--lib/python/qmk/cli/resolve_alias.py16
-rw-r--r--lib/python/qmk/cli/test/__init__.py0
-rw-r--r--lib/python/qmk/cli/test/c.py47
-rw-r--r--lib/python/qmk/cli/userspace/__init__.py5
-rw-r--r--lib/python/qmk/cli/userspace/add.py56
-rw-r--r--lib/python/qmk/cli/userspace/compile.py46
-rw-r--r--lib/python/qmk/cli/userspace/doctor.py13
-rw-r--r--lib/python/qmk/cli/userspace/list.py69
-rwxr-xr-xlib/python/qmk/cli/userspace/path.py8
-rw-r--r--lib/python/qmk/cli/userspace/remove.py41
-rwxr-xr-xlib/python/qmk/cli/via2json.py159
-rw-r--r--lib/python/qmk/commands.py127
-rw-r--r--lib/python/qmk/comment_remover.py20
-rw-r--r--lib/python/qmk/community_modules.py100
-rwxr-xr-xlib/python/qmk/compilation_database.py140
-rw-r--r--lib/python/qmk/constants.py327
-rw-r--r--lib/python/qmk/converter.py33
-rw-r--r--lib/python/qmk/datetime.py29
-rw-r--r--lib/python/qmk/decorators.py102
-rw-r--r--lib/python/qmk/docs.py61
-rw-r--r--lib/python/qmk/errors.py12
-rw-r--r--lib/python/qmk/flashers.py248
-rw-r--r--lib/python/qmk/git.py146
-rw-r--r--lib/python/qmk/importers.py201
-rw-r--r--lib/python/qmk/info.py1131
-rwxr-xr-xlib/python/qmk/json_encoders.py267
-rw-r--r--lib/python/qmk/json_schema.py151
-rw-r--r--lib/python/qmk/keyboard.py401
-rw-r--r--lib/python/qmk/keycodes.py118
-rw-r--r--lib/python/qmk/keymap.py681
-rw-r--r--lib/python/qmk/makefile.py51
-rw-r--r--lib/python/qmk/math_ops.py33
-rw-r--r--lib/python/qmk/painter.py456
-rw-r--r--lib/python/qmk/painter_qff.py401
-rw-r--r--lib/python/qmk/painter_qgf.py460
-rw-r--r--lib/python/qmk/path.py181
-rw-r--r--lib/python/qmk/search.py329
-rw-r--r--lib/python/qmk/submodules.py90
-rw-r--r--lib/python/qmk/tests/.gitignore2
-rw-r--r--lib/python/qmk/tests/__init__.py0
-rw-r--r--lib/python/qmk/tests/attrdict.py8
-rw-r--r--lib/python/qmk/tests/kle.txt5
-rw-r--r--lib/python/qmk/tests/minimal_info.json11
-rw-r--r--lib/python/qmk/tests/minimal_keymap.json7
-rw-r--r--lib/python/qmk/tests/test_cli_commands.py408
-rw-r--r--lib/python/qmk/tests/test_qmk_errors.py8
-rw-r--r--lib/python/qmk/tests/test_qmk_keymap.py47
-rw-r--r--lib/python/qmk/tests/test_qmk_path.py14
-rw-r--r--lib/python/qmk/userspace.py217
-rw-r--r--lib/python/qmk/util.py108
121 files changed, 14617 insertions, 0 deletions
diff --git a/lib/python/kle2xy.py b/lib/python/kle2xy.py
new file mode 100644
index 0000000000..608d1b9809
--- /dev/null
+++ b/lib/python/kle2xy.py
@@ -0,0 +1,126 @@
1""" Original code from https://github.com/skullydazed/kle2xy
2"""
3
4import hjson
5from decimal import Decimal
6
7
8class KLE2xy(list):
9 """Abstract interface for interacting with a KLE layout.
10 """
11 def __init__(self, layout=None, name='', invert_y=True):
12 super(KLE2xy, self).__init__()
13
14 self.name = name
15 self.invert_y = invert_y
16 self.key_width = Decimal('19.05')
17 self.key_skel = {'decal': False, 'border_color': 'none', 'keycap_profile': '', 'keycap_color': 'grey', 'label_color': 'black', 'label_size': 3, 'label_style': 4, 'width': Decimal('1'), 'height': Decimal('1')}
18 self.rows = Decimal(0)
19 self.columns = Decimal(0)
20
21 if layout:
22 self.parse_layout(layout)
23
24 @property
25 def width(self):
26 """Returns the width of the keyboard plate.
27 """
28 return (Decimal(self.columns) * self.key_width) + self.key_width / 2
29
30 @property
31 def height(self):
32 """Returns the height of the keyboard plate.
33 """
34 return (self.rows * self.key_width) + self.key_width / 2
35
36 @property
37 def size(self):
38 """Returns the size of the keyboard plate.
39 """
40 return (self.width, self.height)
41
42 def attrs(self, properties):
43 """Parse the keyboard properties dictionary.
44 """
45 # FIXME: Store more than just the keyboard name.
46 if 'name' in properties:
47 self.name = properties['name']
48
49 def parse_layout(self, layout): # noqa FIXME(skullydazed): flake8 says this has a complexity of 25, it should be refactored.
50 # Wrap this in a dictionary so hjson will parse KLE raw data
51 layout = '{"layout": [' + layout + ']}'
52 layout = hjson.loads(layout)['layout']
53
54 # Initialize our state machine
55 current_key = self.key_skel.copy()
56 current_row = Decimal(0)
57 current_col = Decimal(0)
58
59 if isinstance(layout[0], dict):
60 self.attrs(layout[0])
61 layout = layout[1:]
62
63 for row_num, row in enumerate(layout):
64 self.append([])
65
66 # Process the current row
67 for key in row:
68 if isinstance(key, dict):
69 if 'w' in key and key['w'] != Decimal(1):
70 current_key['width'] = Decimal(key['w'])
71 if 'w2' in key and 'h2' in key and key['w2'] == 1.5 and key['h2'] == 1:
72 # FIXME: ISO Key uses these params: {x:0.25,w:1.25,h:2,w2:1.5,h2:1,x2:-0.25}
73 current_key['isoenter'] = True
74 if 'h' in key and key['h'] != Decimal(1):
75 current_key['height'] = Decimal(key['h'])
76 if 'a' in key:
77 current_key['label_style'] = self.key_skel['label_style'] = max(min(int(key['a']), 9), 0)
78 if 'f' in key:
79 current_key['label_size'] = self.key_skel['label_size'] = max(min(int(key['f']), 9), 1)
80 if 'p' in key:
81 current_key['keycap_profile'] = self.key_skel['keycap_profile'] = key['p']
82 if 'c' in key:
83 current_key['keycap_color'] = self.key_skel['keycap_color'] = key['c']
84 if 't' in key:
85 # FIXME: Need to do better validation, plus figure out how to support multiple colors
86 if '\n' in key['t']:
87 key['t'] = key['t'].split('\n')[0]
88 if key['t'] == "0":
89 key['t'] = "#000000"
90 current_key['label_color'] = self.key_skel['label_color'] = key['t']
91 if 'x' in key:
92 current_col += Decimal(key['x'])
93 if 'y' in key:
94 current_row += Decimal(key['y'])
95 if 'd' in key:
96 current_key['decal'] = True
97
98 else:
99 current_key['name'] = key
100 current_key['row'] = round(current_row, 2)
101 current_key['column'] = round(current_col, 2)
102
103 # x,y (units mm) is the center of the key
104 x_center = current_col + current_key['width'] / 2
105 y_center = current_row + current_key['height'] / 2
106 current_key['x'] = x_center * self.key_width
107 current_key['y'] = y_center * self.key_width
108
109 # Tend to our row/col count
110 current_col += current_key['width']
111 if current_col > self.columns:
112 self.columns = current_col
113
114 # Invert the y-axis if neccesary
115 if self.invert_y:
116 current_key['y'] = -current_key['y']
117
118 # Store this key
119 self[-1].append(current_key)
120 current_key = self.key_skel.copy()
121
122 # Move to the next row
123 current_col = Decimal(0)
124 current_row += Decimal(1)
125 if current_row > self.rows:
126 self.rows = Decimal(current_row)
diff --git a/lib/python/qmk/__init__.py b/lib/python/qmk/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/__init__.py
diff --git a/lib/python/qmk/build_targets.py b/lib/python/qmk/build_targets.py
new file mode 100644
index 0000000000..35a5f89f91
--- /dev/null
+++ b/lib/python/qmk/build_targets.py
@@ -0,0 +1,279 @@
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3import json
4import shutil
5from typing import Dict, List, Union
6from pathlib import Path
7from dotty_dict import dotty, Dotty
8from milc import cli
9from qmk.constants import QMK_FIRMWARE, INTERMEDIATE_OUTPUT_PREFIX, HAS_QMK_USERSPACE, QMK_USERSPACE
10from qmk.commands import find_make, get_make_parallel_args, parse_configurator_json
11from qmk.keyboard import keyboard_folder
12from qmk.info import keymap_json
13from qmk.keymap import locate_keymap
14from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace, unix_style_path
15from qmk.compilation_database import write_compilation_database
16
17# These must be kept in the order in which they're applied to $(TARGET) in the makefiles in order to ensure consistency.
18TARGET_FILENAME_MODIFIERS = ['FORCE_LAYOUT', 'CONVERT_TO']
19
20
21class BuildTarget:
22 def __init__(self, keyboard: str, keymap: str, json: Union[dict, Dotty] = None):
23 self._keyboard = keyboard_folder(keyboard)
24 self._keyboard_safe = self._keyboard.replace('/', '_')
25 self._keymap = keymap
26 self._parallel = 1
27 self._clean = False
28 self._compiledb = False
29 self._extra_args = {}
30 self._json = json.to_dict() if isinstance(json, Dotty) else json
31
32 def __str__(self):
33 return f'{self.keyboard}:{self.keymap}'
34
35 def __repr__(self):
36 if len(self._extra_args.items()) > 0:
37 return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={json.dumps(self._extra_args, sort_keys=True)})'
38 return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap})'
39
40 def __lt__(self, __value: object) -> bool:
41 return self.__repr__() < __value.__repr__()
42
43 def __eq__(self, __value: object) -> bool:
44 if not isinstance(__value, BuildTarget):
45 return False
46 return self.__repr__() == __value.__repr__()
47
48 def __hash__(self) -> int:
49 return self.__repr__().__hash__()
50
51 def configure(self, parallel: int = None, clean: bool = None, compiledb: bool = None) -> None:
52 if parallel is not None:
53 self._parallel = parallel
54 if clean is not None:
55 self._clean = clean
56 if compiledb is not None:
57 self._compiledb = compiledb
58
59 @property
60 def keyboard(self) -> str:
61 return self._keyboard
62
63 @property
64 def keymap(self) -> str:
65 return self._keymap
66
67 @property
68 def json(self) -> dict:
69 if not self._json:
70 self._load_json()
71 if not self._json:
72 return {}
73 return self._json
74
75 @property
76 def dotty(self) -> Dotty:
77 return dotty(self.json)
78
79 @property
80 def extra_args(self) -> Dict[str, str]:
81 return {k: v for k, v in self._extra_args.items()}
82
83 @extra_args.setter
84 def extra_args(self, ex_args: Dict[str, str]):
85 if ex_args is not None and isinstance(ex_args, dict):
86 self._extra_args = {k: v for k, v in ex_args.items()}
87
88 def target_name(self, **env_vars) -> str:
89 # Work out the intended target name
90 target = f'{self._keyboard_safe}_{self.keymap}'
91 vars = self._all_vars(**env_vars)
92 for modifier in TARGET_FILENAME_MODIFIERS:
93 if modifier in vars:
94 target += f"_{vars[modifier]}"
95 return target
96
97 def _all_vars(self, **env_vars) -> Dict[str, str]:
98 vars = {k: v for k, v in env_vars.items()}
99 for k, v in self._extra_args.items():
100 vars[k] = v
101 return vars
102
103 def _intermediate_output(self, **env_vars) -> Path:
104 return Path(f'{INTERMEDIATE_OUTPUT_PREFIX}{self.target_name(**env_vars)}')
105
106 def _common_make_args(self, dry_run: bool = False, build_target: str = None, **env_vars):
107 compile_args = [
108 find_make(),
109 *get_make_parallel_args(self._parallel),
110 '-r',
111 '-R',
112 '-f',
113 'builddefs/build_keyboard.mk',
114 ]
115
116 if not cli.config.general.verbose:
117 compile_args.append('-s')
118
119 verbose = 'true' if cli.config.general.verbose else 'false'
120 color = 'true' if cli.config.general.color else 'false'
121
122 if dry_run:
123 compile_args.append('-n')
124
125 if build_target:
126 compile_args.append(build_target)
127
128 compile_args.extend([
129 f'KEYBOARD={self.keyboard}',
130 f'KEYMAP={self.keymap}',
131 f'KEYBOARD_FILESAFE={self._keyboard_safe}',
132 f'TARGET={self._keyboard_safe}_{self.keymap}', # don't use self.target_name() here, it's rebuilt on the makefile side
133 f'VERBOSE={verbose}',
134 f'COLOR={color}',
135 'SILENT=false',
136 'QMK_BIN="qmk"',
137 ])
138
139 vars = self._all_vars(**env_vars)
140 for k, v in vars.items():
141 compile_args.append(f'{k}={v}')
142
143 return compile_args
144
145 def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
146 raise NotImplementedError("prepare_build() not implemented in base class")
147
148 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
149 raise NotImplementedError("compile_command() not implemented in base class")
150
151 def generate_compilation_database(self, build_target: str = None, skip_clean: bool = False, **env_vars) -> None:
152 self.prepare_build(build_target=build_target, **env_vars)
153 command = self.compile_command(build_target=build_target, dry_run=True, **env_vars)
154 output_path = QMK_FIRMWARE / 'compile_commands.json'
155 ret = write_compilation_database(command=command, output_path=output_path, skip_clean=skip_clean, **env_vars)
156 if ret and output_path.exists() and HAS_QMK_USERSPACE:
157 shutil.copy(str(output_path), str(QMK_USERSPACE / 'compile_commands.json'))
158 return ret
159
160 def compile(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
161 if self._clean or self._compiledb:
162 command = [find_make(), "clean"]
163 if dry_run:
164 command.append('-n')
165 cli.log.info('Cleaning with {fg_cyan}%s', ' '.join(command))
166 cli.run(command, capture_output=False)
167
168 if self._compiledb and not dry_run:
169 self.generate_compilation_database(build_target=build_target, skip_clean=True, **env_vars)
170
171 self.prepare_build(build_target=build_target, dry_run=dry_run, **env_vars)
172 command = self.compile_command(build_target=build_target, **env_vars)
173 cli.log.info('Compiling keymap with {fg_cyan}%s', ' '.join(command))
174 if not dry_run:
175 cli.echo('\n')
176 ret = cli.run(command, capture_output=False)
177 if ret.returncode:
178 return ret.returncode
179
180
181class KeyboardKeymapBuildTarget(BuildTarget):
182 def __init__(self, keyboard: str, keymap: str, json: dict = None):
183 super().__init__(keyboard=keyboard, keymap=keymap, json=json)
184
185 def __repr__(self):
186 if len(self._extra_args.items()) > 0:
187 return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={self._extra_args})'
188 return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap})'
189
190 def _load_json(self):
191 self._json = keymap_json(self.keyboard, self.keymap)
192
193 def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
194 pass
195
196 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
197 compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars)
198
199 # Need to override the keymap path if the keymap is a userspace directory.
200 # This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap
201 # in an equivalent historical location.
202 vars = self._all_vars(**env_vars)
203 keymap_location = locate_keymap(self.keyboard, self.keymap, force_layout=vars.get('FORCE_LAYOUT'))
204 if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location):
205 keymap_directory = keymap_location.parent
206 compile_args.extend([
207 f'MAIN_KEYMAP_PATH_1={unix_style_path(keymap_directory)}',
208 f'MAIN_KEYMAP_PATH_2={unix_style_path(keymap_directory)}',
209 f'MAIN_KEYMAP_PATH_3={unix_style_path(keymap_directory)}',
210 f'MAIN_KEYMAP_PATH_4={unix_style_path(keymap_directory)}',
211 f'MAIN_KEYMAP_PATH_5={unix_style_path(keymap_directory)}',
212 ])
213
214 return compile_args
215
216
217class JsonKeymapBuildTarget(BuildTarget):
218 def __init__(self, json_path):
219 if isinstance(json_path, Path):
220 self.json_path = json_path
221 else:
222 self.json_path = None
223
224 json = parse_configurator_json(json_path) # Will load from stdin if provided
225
226 # In case the user passes a keymap.json from a keymap directory directly to the CLI.
227 # e.g.: qmk compile - < keyboards/clueboard/california/keymaps/default/keymap.json
228 json["keymap"] = json.get("keymap", "default_json")
229
230 super().__init__(keyboard=json['keyboard'], keymap=json['keymap'], json=json)
231
232 def __repr__(self):
233 if len(self._extra_args.items()) > 0:
234 return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path}, extra_args={self._extra_args})'
235 return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path})'
236
237 def _load_json(self):
238 pass # Already loaded in constructor
239
240 def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
241 intermediate_output = self._intermediate_output(**env_vars)
242 generated_files_path = intermediate_output / 'src'
243 keymap_json = generated_files_path / 'keymap.json'
244
245 if self._clean:
246 if intermediate_output.exists():
247 shutil.rmtree(intermediate_output)
248
249 # begin with making the deepest folder in the tree
250 generated_files_path.mkdir(exist_ok=True, parents=True)
251
252 # Compare minified to ensure consistent comparison
253 new_content = json.dumps(self.json, separators=(',', ':'))
254 if keymap_json.exists():
255 old_content = json.dumps(json.loads(keymap_json.read_text(encoding='utf-8')), separators=(',', ':'))
256 if old_content == new_content:
257 new_content = None
258
259 # Write the keymap.json file if different so timestamps are only updated
260 # if the content changes -- running `make` won't treat it as modified.
261 if new_content:
262 keymap_json.write_text(new_content, encoding='utf-8')
263
264 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
265 compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars)
266 intermediate_output = self._intermediate_output(**env_vars)
267 generated_files_path = intermediate_output / 'src'
268 keymap_json = generated_files_path / 'keymap.json'
269 compile_args.extend([
270 f'MAIN_KEYMAP_PATH_1={unix_style_path(intermediate_output)}',
271 f'MAIN_KEYMAP_PATH_2={unix_style_path(intermediate_output)}',
272 f'MAIN_KEYMAP_PATH_3={unix_style_path(intermediate_output)}',
273 f'MAIN_KEYMAP_PATH_4={unix_style_path(intermediate_output)}',
274 f'MAIN_KEYMAP_PATH_5={unix_style_path(intermediate_output)}',
275 f'KEYMAP_JSON={keymap_json}',
276 f'KEYMAP_PATH={generated_files_path}',
277 ])
278
279 return compile_args
diff --git a/lib/python/qmk/c_parse.py b/lib/python/qmk/c_parse.py
new file mode 100644
index 0000000000..785b940456
--- /dev/null
+++ b/lib/python/qmk/c_parse.py
@@ -0,0 +1,324 @@
1"""Functions for working with config.h files.
2"""
3from pygments.lexers.c_cpp import CLexer
4from pygments.token import Token
5from pygments import lex
6from itertools import islice
7from pathlib import Path
8import re
9
10from milc import cli
11
12from qmk.comment_remover import comment_remover
13
14default_key_entry = {'x': -1, 'y': 0}
15single_comment_regex = re.compile(r'\s+/[/*].*$')
16multi_comment_regex = re.compile(r'/\*(.|\n)*?\*/', re.MULTILINE)
17layout_macro_define_regex = re.compile(r'^#\s*define')
18
19
20def _get_chunks(it, size):
21 """Break down a collection into smaller parts
22 """
23 it = iter(it)
24 return iter(lambda: tuple(islice(it, size)), ())
25
26
27def preprocess_c_file(file):
28 """Load file and strip comments
29 """
30 file_contents = file.read_text(encoding='utf-8')
31 file_contents = comment_remover(file_contents)
32 return file_contents.replace('\\\n', '')
33
34
35def strip_line_comment(string):
36 """Removes comments from a single line string.
37 """
38 return single_comment_regex.sub('', string)
39
40
41def strip_multiline_comment(string):
42 """Removes comments from a single line string.
43 """
44 return multi_comment_regex.sub('', string)
45
46
47def c_source_files(dir_names):
48 """Returns a list of all *.c, *.h, and *.cpp files for a given list of directories
49
50 Args:
51
52 dir_names
53 List of directories relative to `qmk_firmware`.
54 """
55 files = []
56 for dir in dir_names:
57 files.extend(file for file in Path(dir).glob('**/*') if file.suffix in ['.c', '.h', '.cpp'])
58 return files
59
60
61def find_layouts(file):
62 """Returns list of parsed LAYOUT preprocessor macros found in the supplied include file.
63 """
64 file = Path(file)
65 aliases = {} # Populated with all `#define`s that aren't functions
66 parsed_layouts = {}
67
68 # Search the file for LAYOUT macros and aliases
69 file_contents = preprocess_c_file(file)
70
71 for line in file_contents.split('\n'):
72 if layout_macro_define_regex.match(line.lstrip()) and '(' in line and 'LAYOUT' in line:
73 # We've found a LAYOUT macro
74 macro_name, layout, matrix = _parse_layout_macro(line.strip())
75
76 # Reject bad macro names
77 if macro_name.startswith('LAYOUT_kc') or not macro_name.startswith('LAYOUT'):
78 continue
79
80 # Parse the matrix data
81 matrix_locations = _parse_matrix_locations(matrix, file, macro_name)
82
83 # Parse the layout entries into a basic structure
84 default_key_entry['x'] = -1 # Set to -1 so _default_key(key) will increment it to 0
85 layout = layout.strip()
86 parsed_layout = [_default_key(key) for key in layout.split(',')]
87
88 for i, key in enumerate(parsed_layout):
89 if 'label' not in key:
90 cli.log.error('Invalid LAYOUT macro in %s: Empty parameter name in macro %s at pos %s.', file, macro_name, i)
91 elif key['label'] not in matrix_locations:
92 cli.log.error('Invalid LAYOUT macro in %s: Key %s in macro %s has no matrix position!', file, key['label'], macro_name)
93 elif len(matrix_locations.get(key['label'])) > 1:
94 cli.log.error('Invalid LAYOUT macro in %s: Key %s in macro %s has multiple matrix positions (%s)', file, key['label'], macro_name, ', '.join(str(x) for x in matrix_locations[key['label']]))
95 else:
96 key['matrix'] = matrix_locations[key['label']][0]
97
98 parsed_layouts[macro_name] = {
99 'layout': parsed_layout,
100 'filename': str(file),
101 }
102
103 elif '#define' in line:
104 # Attempt to extract a new layout alias
105 try:
106 _, pp_macro_name, pp_macro_text = line.strip().split(' ', 2)
107 aliases[pp_macro_name] = pp_macro_text
108 except ValueError:
109 continue
110
111 return parsed_layouts, aliases
112
113
114def parse_config_h_file(config_h_file, config_h=None):
115 """Extract defines from a config.h file.
116 """
117 if not config_h:
118 config_h = {}
119
120 config_h_file = Path(config_h_file)
121
122 if config_h_file.exists():
123 config_h_text = config_h_file.read_text(encoding='utf-8')
124 config_h_text = config_h_text.replace('\\\n', '')
125 config_h_text = strip_multiline_comment(config_h_text)
126
127 for linenum, line in enumerate(config_h_text.split('\n')):
128 line = strip_line_comment(line).strip()
129
130 if not line:
131 continue
132
133 line = line.split()
134
135 if line[0] == '#define':
136 if len(line) == 1:
137 cli.log.error('%s: Incomplete #define! On or around line %s' % (config_h_file, linenum))
138 elif len(line) == 2:
139 config_h[line[1]] = True
140 else:
141 config_h[line[1]] = ' '.join(line[2:])
142
143 elif line[0] == '#undef':
144 if len(line) == 2:
145 if line[1] in config_h:
146 if config_h[line[1]] is True:
147 del config_h[line[1]]
148 else:
149 config_h[line[1]] = False
150 else:
151 cli.log.error('%s: Incomplete #undef! On or around line %s' % (config_h_file, linenum))
152
153 return config_h
154
155
156def _default_key(label=None):
157 """Increment x and return a copy of the default_key_entry.
158 """
159 default_key_entry['x'] += 1
160 new_key = default_key_entry.copy()
161
162 if label:
163 new_key['label'] = label
164
165 return new_key
166
167
168def _parse_layout_macro(layout_macro):
169 """Split the LAYOUT macro into its constituent parts
170 """
171 layout_macro = layout_macro.replace('\\', '').replace(' ', '').replace('\t', '').replace('#define', '')
172 macro_name, layout = layout_macro.split('(', 1)
173 layout, matrix = layout.split(')', 1)
174
175 return macro_name, layout, matrix
176
177
178def _parse_matrix_locations(matrix, file, macro_name):
179 """Parse raw matrix data into a dictionary keyed by the LAYOUT identifier.
180 """
181 matrix_locations = {}
182
183 for row_num, row in enumerate(matrix.split('},{')):
184 if row.startswith('LAYOUT'):
185 cli.log.error('%s: %s: Nested layout macro detected. Matrix data not available!', file, macro_name)
186 break
187
188 row = row.replace('{', '').replace('}', '')
189 for col_num, identifier in enumerate(row.split(',')):
190 if identifier != 'KC_NO':
191 if identifier not in matrix_locations:
192 matrix_locations[identifier] = []
193 matrix_locations[identifier].append([row_num, col_num])
194
195 return matrix_locations
196
197
198def _coerce_led_token(_type, value):
199 """ Convert token to valid info.json content
200 """
201 value_map = {
202 'NO_LED': None,
203 'LED_FLAG_ALL': 0xFF,
204 'LED_FLAG_NONE': 0x00,
205 'LED_FLAG_MODIFIER': 0x01,
206 'LED_FLAG_UNDERGLOW': 0x02,
207 'LED_FLAG_KEYLIGHT': 0x04,
208 'LED_FLAG_INDICATOR': 0x08,
209 }
210 if _type is Token.Literal.Number.Integer:
211 return int(value)
212 if _type is Token.Literal.Number.Float:
213 return float(value)
214 if _type is Token.Literal.Number.Hex:
215 return int(value, 0)
216 if _type is Token.Name and value in value_map.keys():
217 return value_map[value]
218
219
220def _validate_led_config(matrix, matrix_rows, matrix_cols, matrix_indexes, position, position_raw, flags):
221 # TODO: Improve crude parsing/validation
222 if len(matrix) != matrix_rows and len(matrix) != (matrix_rows / 2):
223 raise ValueError("Unable to parse g_led_config matrix data")
224 for index, row in enumerate(matrix):
225 if len(row) != matrix_cols:
226 raise ValueError(f"Number of columns in row {index} ({len(row)}) does not match matrix ({matrix_cols})")
227 if len(position) != len(flags):
228 raise ValueError(f"Number of g_led_config physical positions ({len(position)}) does not match number of flags ({len(flags)})")
229 if len(matrix_indexes) and (max(matrix_indexes) >= len(flags)):
230 raise ValueError(f"LED index {max(matrix_indexes)} is OOB in g_led_config - should be < {len(flags)}")
231 if not all(isinstance(n, int) for n in matrix_indexes):
232 raise ValueError("matrix indexes are not all ints")
233 if (len(position_raw) % 2) != 0:
234 raise ValueError("Malformed g_led_config position data")
235
236
237def _parse_led_config(file, matrix_cols, matrix_rows):
238 """Return any 'raw' led/rgb matrix config
239 """
240 matrix = []
241 position_raw = []
242 flags = []
243
244 found_led_config_t = False
245 found_g_led_config = False
246 bracket_count = 0
247 section = 0
248 current_row_index = 0
249 current_row = []
250
251 for _type, value in lex(preprocess_c_file(file), CLexer()):
252 if not found_g_led_config:
253 # Check for type
254 if value == 'led_config_t':
255 found_led_config_t = True
256 # Type found, now check for name
257 elif found_led_config_t and value == 'g_led_config':
258 found_g_led_config = True
259 elif value == ';':
260 found_g_led_config = False
261 else:
262 # Assume bracket count hints to section of config we are within
263 if value == '{':
264 bracket_count += 1
265 if bracket_count == 2:
266 section += 1
267 elif value == '}':
268 if section == 1 and bracket_count == 3:
269 matrix.append(current_row)
270 current_row = []
271 current_row_index += 1
272 bracket_count -= 1
273 else:
274 # Assume any non whitespace value here is important enough to stash
275 if _type in [Token.Literal.Number.Integer, Token.Literal.Number.Float, Token.Literal.Number.Hex, Token.Name]:
276 if section == 1 and bracket_count == 3:
277 current_row.append(_coerce_led_token(_type, value))
278 if section == 2 and bracket_count == 3:
279 position_raw.append(_coerce_led_token(_type, value))
280 if section == 3 and bracket_count == 2:
281 flags.append(_coerce_led_token(_type, value))
282 elif _type in [Token.Comment.Preproc]:
283 # TODO: Promote to error
284 return None
285
286 # Slightly better intrim format
287 position = list(_get_chunks(position_raw, 2))
288 matrix_indexes = list(filter(lambda x: x is not None, sum(matrix, [])))
289
290 # If we have not found anything - bail with no error
291 if not section:
292 return None
293
294 # Throw any validation errors
295 _validate_led_config(matrix, matrix_rows, matrix_cols, matrix_indexes, position, position_raw, flags)
296
297 return (matrix, position, flags)
298
299
300def find_led_config(file, matrix_cols, matrix_rows):
301 """Search file for led/rgb matrix config
302 """
303 found = _parse_led_config(file, matrix_cols, matrix_rows)
304 if not found:
305 return None
306
307 # Expand collected content
308 (matrix, position, flags) = found
309
310 # Align to output format
311 led_config = []
312 for index, item in enumerate(position, start=0):
313 led_config.append({
314 'x': item[0],
315 'y': item[1],
316 'flags': flags[index],
317 })
318 for r in range(len(matrix)):
319 for c in range(len(matrix[r])):
320 index = matrix[r][c]
321 if index is not None:
322 led_config[index]['matrix'] = [r, c]
323
324 return led_config
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py
new file mode 100644
index 0000000000..dc2e4726a5
--- /dev/null
+++ b/lib/python/qmk/cli/__init__.py
@@ -0,0 +1,288 @@
1"""QMK CLI Subcommands
2
3We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
4"""
5import os
6import platform
7import platformdirs
8import shlex
9import sys
10from importlib.util import find_spec
11from pathlib import Path
12from subprocess import run
13
14from milc import cli, __VERSION__
15from milc.questions import yesno
16
17
18def _get_default_distrib_path():
19 if 'windows' in platform.platform().lower():
20 try:
21 result = cli.run(['cygpath', '-w', '/opt/qmk'])
22 if result.returncode == 0:
23 return result.stdout.strip()
24 except Exception:
25 pass
26
27 return platformdirs.user_data_dir('qmk')
28
29
30# Ensure the QMK distribution is on the `$PATH` if present. This must be kept in sync with qmk/qmk_cli.
31QMK_DISTRIB_DIR = Path(os.environ.get('QMK_DISTRIB_DIR', _get_default_distrib_path()))
32if QMK_DISTRIB_DIR.exists():
33 os.environ['PATH'] = str(QMK_DISTRIB_DIR / 'bin') + os.pathsep + os.environ['PATH']
34
35# Prepend any user-defined path prefix
36if 'QMK_PATH_PREFIX' in os.environ:
37 os.environ['PATH'] = os.environ['QMK_PATH_PREFIX'] + os.pathsep + os.environ['PATH']
38
39import_names = {
40 # A mapping of package name to importable name
41 'pep8-naming': 'pep8ext_naming',
42 'pyserial': 'serial',
43 'pyusb': 'usb.core',
44 'qmk-dotty-dict': 'dotty_dict',
45 'pillow': 'PIL'
46}
47
48safe_commands = [
49 # A list of subcommands we always run, even when the module imports fail
50 'clone',
51 'config',
52 'doctor',
53 'env',
54 'setup',
55]
56
57subcommands = [
58 'qmk.cli.ci.validate_aliases',
59 'qmk.cli.bux',
60 'qmk.cli.c2json',
61 'qmk.cli.cd',
62 'qmk.cli.chibios.confmigrate',
63 'qmk.cli.clean',
64 'qmk.cli.compile',
65 'qmk.cli.docs',
66 'qmk.cli.doctor',
67 'qmk.cli.find',
68 'qmk.cli.flash',
69 'qmk.cli.format.c',
70 'qmk.cli.format.json',
71 'qmk.cli.format.python',
72 'qmk.cli.format.text',
73 'qmk.cli.generate.api',
74 'qmk.cli.generate.autocorrect_data',
75 'qmk.cli.generate.compilation_database',
76 'qmk.cli.generate.community_modules',
77 'qmk.cli.generate.config_h',
78 'qmk.cli.generate.develop_pr_list',
79 'qmk.cli.generate.dfu_header',
80 'qmk.cli.generate.docs',
81 'qmk.cli.generate.info_json',
82 'qmk.cli.generate.keyboard_c',
83 'qmk.cli.generate.keyboard_h',
84 'qmk.cli.generate.keycodes',
85 'qmk.cli.generate.keymap_h',
86 'qmk.cli.generate.make_dependencies',
87 'qmk.cli.generate.rgb_breathe_table',
88 'qmk.cli.generate.rules_mk',
89 'qmk.cli.generate.version_h',
90 'qmk.cli.git.submodule',
91 'qmk.cli.hello',
92 'qmk.cli.import.kbfirmware',
93 'qmk.cli.import.keyboard',
94 'qmk.cli.import.keymap',
95 'qmk.cli.info',
96 'qmk.cli.json2c',
97 'qmk.cli.license_check',
98 'qmk.cli.lint',
99 'qmk.cli.kle2json',
100 'qmk.cli.list.keyboards',
101 'qmk.cli.list.keymaps',
102 'qmk.cli.list.layouts',
103 'qmk.cli.mass_compile',
104 'qmk.cli.migrate',
105 'qmk.cli.new.keyboard',
106 'qmk.cli.new.keymap',
107 'qmk.cli.painter',
108 'qmk.cli.pytest',
109 'qmk.cli.resolve_alias',
110 'qmk.cli.test.c',
111 'qmk.cli.userspace.add',
112 'qmk.cli.userspace.compile',
113 'qmk.cli.userspace.doctor',
114 'qmk.cli.userspace.list',
115 'qmk.cli.userspace.path',
116 'qmk.cli.userspace.remove',
117 'qmk.cli.via2json',
118]
119
120
121def _install_deps(requirements):
122 """Perform the installation of missing requirements.
123
124 If we detect that we are running in a virtualenv we can't write into we'll use sudo to perform the pip install.
125 """
126 command = [sys.executable, '-m', 'pip', 'install']
127
128 if sys.prefix != sys.base_prefix:
129 # We are in a virtualenv, check to see if we need to use sudo to write to it
130 if not os.access(sys.prefix, os.W_OK):
131 print('Notice: Using sudo to install modules to location owned by root:', sys.prefix)
132 command.insert(0, 'sudo')
133
134 elif not os.access(sys.prefix, os.W_OK):
135 # We can't write to sys.prefix, attempt to install locally
136 command.append('--user')
137
138 return _run_cmd(*command, '-r', requirements)
139
140
141def _run_cmd(*command):
142 """Run a command in a subshell.
143 """
144 if 'windows' in cli.platform.lower():
145 safecmd = map(shlex.quote, command)
146 safecmd = ' '.join(safecmd)
147 command = [os.environ['SHELL'], '-c', safecmd]
148
149 return run(command)
150
151
152def _find_broken_requirements(requirements):
153 """ Check if the modules in the given requirements.txt are available.
154
155 Args:
156
157 requirements
158 The path to a requirements.txt file
159
160 Returns a list of modules that couldn't be imported
161 """
162 with Path(requirements).open() as fd:
163 broken_modules = []
164
165 for line in fd.readlines():
166 line = line.strip().replace('<', '=').replace('>', '=')
167
168 if len(line) == 0 or line[0] == '#' or line.startswith('-r'):
169 continue
170
171 if '#' in line:
172 line = line.split('#')[0]
173
174 module_name = line.split('=')[0] if '=' in line else line
175 module_import = module_name.replace('-', '_')
176
177 # Not every module is importable by its own name.
178 if module_name in import_names:
179 module_import = import_names[module_name]
180
181 if not find_spec(module_import):
182 broken_modules.append(module_name)
183
184 return broken_modules
185
186
187def _broken_module_imports(requirements):
188 """Make sure we can import all the python modules.
189 """
190 broken_modules = _find_broken_requirements(requirements)
191
192 for module in broken_modules:
193 print('Could not find module %s!' % module)
194
195 if broken_modules:
196 return True
197
198 return False
199
200
201def _yesno(*args):
202 """Wrapper to only prompt if interactive
203 """
204 return sys.stdout.isatty() and yesno(*args)
205
206
207def _eprint(errmsg):
208 """Wrapper to print to stderr
209 """
210 print(errmsg, file=sys.stderr)
211
212
213# Make sure our python is new enough
214#
215# Supported version information
216#
217# Based on the OSes we support these are the minimum python version available by default.
218# Last update: 2024 Jun 24
219#
220# Arch: 3.12
221# Debian 11: 3.9
222# Debian 12: 3.11
223# Fedora 39: 3.12
224# Fedora 40: 3.12
225# FreeBSD: 3.11
226# Gentoo: 3.12
227# macOS: 3.12 (from homebrew)
228# msys2: 3.11
229# Slackware: 3.9
230# solus: 3.10
231# Ubuntu 22.04: 3.10
232# Ubuntu 24.04: 3.12
233# void: 3.12
234
235if sys.version_info[0] != 3 or sys.version_info[1] < 9:
236 _eprint('Error: Your Python is too old! Please upgrade to Python 3.9 or later.')
237 exit(127)
238
239milc_version = __VERSION__.split('.')
240
241if int(milc_version[0]) < 2 and int(milc_version[1]) < 9:
242 requirements = Path('requirements.txt').resolve()
243
244 _eprint(f'Your MILC library is too old! Please upgrade: python3 -m pip install -U -r {str(requirements)}')
245 exit(127)
246
247# Make sure we can run binaries in the same directory as our Python interpreter
248python_dir = os.path.dirname(sys.executable)
249
250if python_dir not in os.environ['PATH'].split(os.pathsep):
251 os.environ['PATH'] = os.pathsep.join((python_dir, os.environ['PATH']))
252
253# Check to make sure we have all our dependencies
254msg_install = f'\nPlease run `{sys.executable} -m pip install -r %s` to install required python dependencies.'
255args = sys.argv[1:]
256while args and args[0][0] == '-':
257 del args[0]
258
259safe_command = args and args[0] in safe_commands
260
261if not safe_command:
262 if _broken_module_imports('requirements.txt'):
263 if _yesno('Would you like to install the required Python modules?'):
264 _install_deps('requirements.txt')
265 else:
266 _eprint(msg_install % (str(Path('requirements.txt').resolve()),))
267 exit(1)
268
269 if cli.config.user.developer and _broken_module_imports('requirements-dev.txt'):
270 if _yesno('Would you like to install the required developer Python modules?'):
271 _install_deps('requirements-dev.txt')
272 elif _yesno('Would you like to disable developer mode?'):
273 _run_cmd(sys.argv[0], 'config', 'user.developer=None')
274 else:
275 _eprint(msg_install % (str(Path('requirements-dev.txt').resolve()),))
276 _eprint('You can also turn off developer mode: qmk config user.developer=None')
277 exit(1)
278
279# Import our subcommands
280for subcommand in subcommands:
281 try:
282 __import__(subcommand)
283
284 except (ImportError, ModuleNotFoundError) as e:
285 if safe_command:
286 _eprint(f'Warning: Could not import {subcommand}: {e.__class__.__name__}, {e}')
287 else:
288 raise
diff --git a/lib/python/qmk/cli/bux.py b/lib/python/qmk/cli/bux.py
new file mode 100755
index 0000000000..669521d08e
--- /dev/null
+++ b/lib/python/qmk/cli/bux.py
@@ -0,0 +1,49 @@
1"""QMK Bux
2
3World domination secret weapon.
4"""
5from milc import cli
6from milc.subcommand import config
7
8
9@cli.subcommand('QMK Bux miner.', hidden=True)
10def bux(cli):
11 """QMK bux
12 """
13 if not cli.config.user.bux:
14 bux = 0
15 else:
16 bux = cli.config.user.bux
17
18 cli.args.read_only = False
19 config.set_config('user', 'bux', bux + 1)
20 cli.save_config()
21
22 buck = r"""
23@@BBBBBBBBBBBBBBBBBBBBK `vP8#####BE2~ x###g_ `S###q n##} -j#Bl. vBBBBBBBBBBBBBBBBBBBB@@
24@B `:!: ^#@#]- `!t@@&. 7@@B@#^ _Q@Q@@R y@@l:P@#1' `!!_ B@
25@B r@@@B g@@| ` N@@u 7@@iv@@u *#@z"@@R y@@&@@Q- l@@@D B@
26@B !#@B ^#@#x- I@B@@&' 7@@i "B@Q@@r _@@R y@@l.k#@W: `:@@D B@
27@B B@B `v3g#####B0N#d. v##x 'ckk: -##A u##i `lB#I_ @@D B@
28@B B@B @@D B@
29@B B@B `._":!!!=~^*|)r^~:' @@D B@
30@B ~*~ `,=)]}y2tjIIfKfKfaPsffsWsUyx~. **! B@
31@B .*r***r= _*]yzKsqKUfz22IAA3HzzUjtktzHWsHsIz]. B@
32@B )v` , !1- -rysHHUzUzo2jzoI22ztzkyykt2zjzUzIa3qPsl' !r*****` B@
33@B :} @` .j `xzqdAfzKWsj2kkcycczqAsk2zHbg&ER5q55SNN5U~ !RBB#d`c#1 f#\BQ&v B@
34@B _y ]# ,c vUWNWWPsfsssN9WyccnckAfUfWb0DR0&R5RRRddq2_ `@D`jr@2U@#c3@1@Qc- B@
35@B !7! .r]` }AE0RdRqNd9dNR9fUIzzosPqqAddNNdER9EE9dPy! BQ!zy@iU@.Q@@y@8x- B@
36@B :****>. '7adddDdR&gRNdRbd&dNNbbRdNdd5NdRRD0RSf}- .k0&EW`xR .8Q=NRRx B@
37@B =**-rx*r}r~}" ;n2jkzsf3N3zsKsP5dddRddddRddNNqPzy\" '~****" B@
38@B :!!~!;=~r>:*_ `:^vxikylulKfHkyjzzozoIoklix|^!-` B@
39@B ```'-_""::::!:_-.`` B@
40@B `- .` B@
41@B r@= In source we trust @H B@
42@B r@= @H B@
43@B -g@= `}&###E7 W#g. :#Q n####~ R###8k ;#& `##.7#8-`R#z t@H B@
44@B r@= 8@R=-=R@g R@@#:!@@ 2@&!:` 8@1=@@!*@B `@@- v@#8@y @H B@
45@B r@= :@@- _@@_R@fB#}@@ 2@@@# 8@@#@Q.*@B `@@- y@@N @H B@
46@B `. g@9=_~D@g R@}`&@@@ 2@&__` 8@u_Q@2!@@^-x@@` Y@QD@z .` B@
47@@BBBBBBBBBBBBBBBBBBB_ `c8@@@81` S#] `N#B l####v D###BA. vg@@#0~ i#&' 5#K RBBBBBBBBBBBBBBBBBB@@
48""" # noqa: Do not care about the ASCII art
49 print(f"{buck}\nYou've been blessed by the QMK gods!\nYou have {cli.config.user.bux} QMK bux.")
diff --git a/lib/python/qmk/cli/c2json.py b/lib/python/qmk/cli/c2json.py
new file mode 100644
index 0000000000..f7f1f2ffba
--- /dev/null
+++ b/lib/python/qmk/cli/c2json.py
@@ -0,0 +1,73 @@
1"""Generate a keymap.json from a keymap.c file.
2"""
3import re
4import json
5
6from argcomplete.completers import FilesCompleter
7from milc import cli
8
9import qmk.path
10from qmk.json_encoders import InfoJSONEncoder
11from qmk.decorators import automagic_keyboard, automagic_keymap
12from qmk.keyboard import keyboard_completer, keyboard_folder
13from qmk.keymap import locate_keymap, find_keymap_from_dir, generate_json, c2json as c2json_impl
14from qmk.errors import CppError
15from qmk.commands import dump_lines
16
17
18@cli.argument('--no-cpp', arg_only=True, action='store_false', help='Do not use \'cpp\' on keymap.c')
19@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
20@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
21@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='The keyboard\'s name')
22@cli.argument('-km', '--keymap', help='The keymap\'s name')
23@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.c'), help='keymap.c file')
24@cli.subcommand('Creates a keymap.json from a keymap.c file.')
25@automagic_keyboard
26@automagic_keymap
27def c2json(cli):
28 """Generate a keymap.json from a keymap.c file.
29
30 This command uses the `qmk.keymap` module to generate a keymap.json from a keymap.c file. The generated keymap is written to stdout, or to a file if -o is provided.
31 """
32 filename = cli.args.filename
33 keyboard = cli.config.c2json.keyboard
34 keymap = cli.config.c2json.keymap
35
36 if filename:
37 if not keyboard and not keymap:
38 # fallback to inferring keyboard/keymap from path
39 (keymap, found_type) = find_keymap_from_dir(filename)
40 if found_type == 'keymap_directory':
41 keyboard = re.search(fr"keyboards/(.+)/keymaps/{keymap}/.*", filename.as_posix()).group(1)
42
43 elif keyboard and keymap:
44 if not filename:
45 # fallback to inferring keyboard/keymap from path
46 filename = locate_keymap(keyboard, keymap)
47
48 if not all((filename, keyboard, keymap)):
49 cli.log.error('You must supply keyboard and keymap, a path to a keymap.c within qmk_firmware, or absolute filename and keyboard and keymap')
50 cli.print_help()
51 return False
52
53 try:
54 keymap_json = c2json_impl(keyboard, keymap, filename, use_cpp=cli.args.no_cpp)
55 except CppError as e:
56 if cli.config.general.verbose:
57 cli.log.debug('The C pre-processor ran into a fatal error: %s', e)
58 cli.log.error('Something went wrong. Try to use --no-cpp.\nUse the CLI in verbose mode to find out more.')
59 return False
60
61 # Generate the keymap.json
62 try:
63 keymap_json = generate_json(keymap_json['keymap'], keymap_json['keyboard'], keymap_json['layout'], keymap_json['layers'])
64 except KeyError:
65 cli.log.error('Something went wrong. Try to use --no-cpp.')
66 return False
67
68 if cli.args.output:
69 keymap_lines = [json.dumps(keymap_json, cls=InfoJSONEncoder, sort_keys=True)]
70 else:
71 keymap_lines = [json.dumps(keymap_json)]
72
73 dump_lines(cli.args.output, keymap_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/cd.py b/lib/python/qmk/cli/cd.py
new file mode 100755
index 0000000000..ef03011f1f
--- /dev/null
+++ b/lib/python/qmk/cli/cd.py
@@ -0,0 +1,47 @@
1"""Open a shell in the QMK Home directory
2"""
3import sys
4import os
5import subprocess
6
7from milc import cli
8
9from qmk.path import under_qmk_firmware
10
11
12@cli.subcommand('Go to QMK Home')
13def cd(cli):
14 """Go to QMK Home
15 """
16 if not sys.stdout.isatty():
17 cli.log.error("This command is for interactive usage only. For non-interactive usage, 'cd $(qmk env QMK_HOME)' is more robust.")
18 sys.exit(1)
19
20 if not under_qmk_firmware():
21 # Only do anything if the user is not under qmk_firmware already
22 # in order to reduce the possibility of starting multiple shells
23 cli.log.info("Spawning a subshell in your QMK_HOME directory.")
24 cli.log.info("Type 'exit' to get back to the parent shell.")
25 if not cli.platform.lower().startswith('windows'):
26 # For Linux/Mac/etc
27 # Check the user's login shell from 'passwd'
28 # alternatively fall back to $SHELL env var
29 # and finally to '/bin/bash'.
30 import getpass
31 import pwd
32 shell = pwd.getpwnam(getpass.getuser()).pw_shell
33 if not shell:
34 shell = os.environ.get('SHELL', '/bin/bash')
35 # Start the new subshell
36 os.execl(shell, shell)
37 else:
38 # For Windows
39 # Check the $SHELL env var
40 # and fall back to '/usr/bin/bash'.
41 qmk_env = os.environ.copy()
42 # Set the prompt for the new shell
43 qmk_env['MSYS2_PS1'] = qmk_env['PS1']
44 # Start the new subshell
45 subprocess.run([os.environ.get('SHELL', '/usr/bin/bash')], env=qmk_env)
46 else:
47 cli.log.info("Already within qmk_firmware directory.")
diff --git a/lib/python/qmk/cli/chibios/__init__.py b/lib/python/qmk/cli/chibios/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/chibios/__init__.py
diff --git a/lib/python/qmk/cli/chibios/confmigrate.py b/lib/python/qmk/cli/chibios/confmigrate.py
new file mode 100644
index 0000000000..be1f2cd744
--- /dev/null
+++ b/lib/python/qmk/cli/chibios/confmigrate.py
@@ -0,0 +1,162 @@
1"""This script automates the copying of the default keymap into your own keymap.
2"""
3import re
4import sys
5import os
6
7from qmk.constants import QMK_FIRMWARE
8from qmk.path import normpath
9from milc import cli
10
11
12def eprint(*args, **kwargs):
13 print(*args, file=sys.stderr, **kwargs)
14
15
16file_header = """\
17/* Copyright 2020 QMK
18 *
19 * This program is free software: you can redistribute it and/or modify
20 * it under the terms of the GNU General Public License as published by
21 * the Free Software Foundation, either version 2 of the License, or
22 * (at your option) any later version.
23 *
24 * This program is distributed in the hope that it will be useful,
25 * but WITHOUT ANY WARRANTY; without even the implied warranty of
26 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
27 * GNU General Public License for more details.
28 *
29 * You should have received a copy of the GNU General Public License
30 * along with this program. If not, see <http://www.gnu.org/licenses/>.
31 */
32
33/*
34 * This file was auto-generated by:
35 * `qmk chibios-confmigrate -i {0} -r {1}`
36 */
37
38#pragma once
39"""
40
41
42def collect_defines(filepath):
43 with open(filepath, 'r', encoding='utf-8') as f:
44 content = f.read()
45 define_search = re.compile(r'(?m)^#\s*define\s+(?:.*\\\r?\n)*.*$', re.MULTILINE)
46 value_search = re.compile(r'^#\s*define\s+(?P<name>[a-zA-Z0-9_]+(\([^\)]*\))?)\s*(?P<value>.*)', re.DOTALL)
47 define_matches = define_search.findall(content)
48
49 defines = {"keys": [], "dict": {}}
50 for define_match in define_matches:
51 value_match = value_search.search(define_match)
52 defines["keys"].append(value_match.group("name"))
53 defines["dict"][value_match.group("name")] = value_match.group("value")
54 return defines
55
56
57def check_diffs(input_defs, reference_defs):
58 not_present_in_input = []
59 not_present_in_reference = []
60 to_override = []
61
62 for key in reference_defs["keys"]:
63 if key not in input_defs["dict"]:
64 not_present_in_input.append(key)
65 continue
66
67 for key in input_defs["keys"]:
68 if key not in input_defs["dict"]:
69 not_present_in_input.append(key)
70 continue
71
72 for key in input_defs["keys"]:
73 if key in reference_defs["keys"] and input_defs["dict"][key] != reference_defs["dict"][key]:
74 to_override.append((key, input_defs["dict"][key]))
75
76 return (to_override, not_present_in_input, not_present_in_reference)
77
78
79def migrate_chconf_h(to_override, outfile):
80 print(file_header.format(cli.args.input.relative_to(QMK_FIRMWARE), cli.args.reference.relative_to(QMK_FIRMWARE)), file=outfile)
81
82 for override in to_override:
83 print("#define %s %s" % (override[0], override[1]), file=outfile)
84 print("", file=outfile)
85
86 print("#include_next <chconf.h>\n", file=outfile)
87
88
89def migrate_halconf_h(to_override, outfile):
90 print(file_header.format(cli.args.input.relative_to(QMK_FIRMWARE), cli.args.reference.relative_to(QMK_FIRMWARE)), file=outfile)
91
92 for override in to_override:
93 print("#define %s %s" % (override[0], override[1]), file=outfile)
94 print("", file=outfile)
95
96 print("#include_next <halconf.h>\n", file=outfile)
97
98
99def migrate_mcuconf_h(to_override, outfile):
100 print(file_header.format(cli.args.input.relative_to(QMK_FIRMWARE), cli.args.reference.relative_to(QMK_FIRMWARE)), file=outfile)
101
102 print("#include_next <mcuconf.h>\n", file=outfile)
103
104 for override in to_override:
105 print("#undef %s" % (override[0]), file=outfile)
106 print("#define %s %s" % (override[0], override[1]), file=outfile)
107 print("", file=outfile)
108
109
110@cli.argument('-i', '--input', type=normpath, arg_only=True, required=True, help='Specify input config file.')
111@cli.argument('-r', '--reference', type=normpath, arg_only=True, required=True, help='Specify the reference file to compare against')
112@cli.argument('-o', '--overwrite', arg_only=True, action='store_true', help='Overwrites the input file during migration.')
113@cli.argument('-d', '--delete', arg_only=True, action='store_true', help='If the file has no overrides, migration will delete the input file.')
114@cli.argument('-f', '--force', arg_only=True, action='store_true', help='Re-migrates an already migrated file, even if it doesn\'t detect a full ChibiOS config.')
115@cli.subcommand('Generates a migrated ChibiOS configuration file, as a result of comparing the input against a reference')
116def chibios_confmigrate(cli):
117 """Generates a usable ChibiOS replacement configuration file, based on a fully-defined conf and a reference config.
118 """
119
120 input_defs = collect_defines(cli.args.input)
121 reference_defs = collect_defines(cli.args.reference)
122
123 (to_override, not_present_in_input, not_present_in_reference) = check_diffs(input_defs, reference_defs)
124
125 if len(not_present_in_input) > 0:
126 eprint("Keys not in input, but present inside reference (potential manual migration required):")
127 for key in not_present_in_input:
128 eprint(" %s" % (key))
129
130 if len(not_present_in_reference) > 0:
131 eprint("Keys not in reference, but present inside input (potential manual migration required):")
132 for key in not_present_in_reference:
133 eprint(" %s" % (key))
134
135 if len(to_override) == 0:
136 eprint('No overrides found! If there were no missing keys above, it should be safe to delete the input file.')
137 if cli.args.delete:
138 os.remove(cli.args.input)
139 else:
140 eprint('Overrides found:')
141 for override in to_override:
142 eprint("%40s: %s -> %s" % (override[0], reference_defs["dict"][override[0]].encode('unicode_escape').decode("utf-8"), override[1].encode('unicode_escape').decode("utf-8")))
143
144 eprint('--------------------------------------')
145
146 if cli.args.input.name == "chconf.h" and ("CHCONF_H" in input_defs["dict"] or "_CHCONF_H_" in input_defs["dict"] or cli.args.force):
147 migrate_chconf_h(to_override, outfile=sys.stdout)
148 if cli.args.overwrite:
149 with open(cli.args.input, "w", encoding='utf-8') as out_file:
150 migrate_chconf_h(to_override, outfile=out_file)
151
152 elif cli.args.input.name == "halconf.h" and ("HALCONF_H" in input_defs["dict"] or "_HALCONF_H_" in input_defs["dict"] or cli.args.force):
153 migrate_halconf_h(to_override, outfile=sys.stdout)
154 if cli.args.overwrite:
155 with open(cli.args.input, "w", encoding='utf-8') as out_file:
156 migrate_halconf_h(to_override, outfile=out_file)
157
158 elif cli.args.input.name == "mcuconf.h" and ("MCUCONF_H" in input_defs["dict"] or "_MCUCONF_H_" in input_defs["dict"] or cli.args.force):
159 migrate_mcuconf_h(to_override, outfile=sys.stdout)
160 if cli.args.overwrite:
161 with open(cli.args.input, "w", encoding='utf-8') as out_file:
162 migrate_mcuconf_h(to_override, outfile=out_file)
diff --git a/lib/python/qmk/cli/ci/__init__.py b/lib/python/qmk/cli/ci/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/ci/__init__.py
diff --git a/lib/python/qmk/cli/ci/validate_aliases.py b/lib/python/qmk/cli/ci/validate_aliases.py
new file mode 100644
index 0000000000..4f2fe6c941
--- /dev/null
+++ b/lib/python/qmk/cli/ci/validate_aliases.py
@@ -0,0 +1,58 @@
1"""Validates the list of keyboard aliases.
2"""
3from milc import cli
4
5from qmk.keyboard import keyboard_folder, keyboard_alias_definitions
6
7
8def _safe_keyboard_folder(target):
9 try:
10 return keyboard_folder(target) # throws ValueError if it's invalid
11 except Exception:
12 return None
13
14
15def _target_keyboard_exists(target):
16 # If there's no target, then we can't build it.
17 if not target:
18 return False
19
20 # If the target directory exists but it itself has an invalid alias or invalid rules.mk, then we can't build it either.
21 if not _safe_keyboard_folder(target):
22 return False
23
24 # As far as we can tell, we can build it!
25 return True
26
27
28def _alias_not_self(alias):
29 """Check if alias points to itself, either directly or within a circular reference
30 """
31 aliases = keyboard_alias_definitions()
32
33 found = set()
34 while alias in aliases:
35 found.add(alias)
36 alias = aliases[alias].get('target', alias)
37 if alias in found:
38 return False
39
40 return True
41
42
43@cli.subcommand('Validates the list of keyboard aliases.', hidden=True)
44def ci_validate_aliases(cli):
45 aliases = keyboard_alias_definitions()
46
47 success = True
48 for alias in aliases.keys():
49 target = aliases[alias].get('target', None)
50 if not _alias_not_self(alias):
51 cli.log.error(f'Keyboard alias {alias} should not point to itself')
52 success = False
53
54 elif not _target_keyboard_exists(target):
55 cli.log.error(f'Keyboard alias {alias} has a target that doesn\'t exist: {target}')
56 success = False
57
58 return success
diff --git a/lib/python/qmk/cli/clean.py b/lib/python/qmk/cli/clean.py
new file mode 100644
index 0000000000..bdec01e4b6
--- /dev/null
+++ b/lib/python/qmk/cli/clean.py
@@ -0,0 +1,14 @@
1"""Clean the QMK firmware folder of build artifacts.
2"""
3from subprocess import DEVNULL
4
5from qmk.commands import find_make
6from milc import cli
7
8
9@cli.argument('-a', '--all', arg_only=True, action='store_true', help='Remove *.hex and *.bin files in the QMK root as well.')
10@cli.subcommand('Clean the QMK firmware folder of build artifacts.')
11def clean(cli):
12 """Runs `make clean` (or `make distclean` if --all is passed)
13 """
14 cli.run([find_make(), 'distclean' if cli.args.all else 'clean'], capture_output=False, stdin=DEVNULL)
diff --git a/lib/python/qmk/cli/compile.py b/lib/python/qmk/cli/compile.py
new file mode 100755
index 0000000000..8d1195bc8f
--- /dev/null
+++ b/lib/python/qmk/cli/compile.py
@@ -0,0 +1,83 @@
1"""Compile a QMK Firmware.
2
3You can compile a keymap already in the repo or using a QMK Configurator export.
4"""
5from argcomplete.completers import FilesCompleter
6
7from milc import cli
8
9import qmk.path
10from qmk.decorators import automagic_keyboard, automagic_keymap
11from qmk.commands import build_environment
12from qmk.keyboard import keyboard_completer, keyboard_folder_or_all, is_all_keyboards
13from qmk.keymap import keymap_completer, locate_keymap
14from qmk.build_targets import KeyboardKeymapBuildTarget, JsonKeymapBuildTarget
15
16
17@cli.argument('filename', nargs='?', arg_only=True, type=qmk.path.FileType('r'), completer=FilesCompleter('.json'), help='The configurator export to compile')
18@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
19@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
20@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually build, just show the make command to be run.")
21@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
22@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.")
23@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
24@cli.argument('-t', '--target', type=str, default=None, help="Intended alternative build target, such as `production` in `make planck/rev4:default:production`.")
25@cli.argument('--compiledb', arg_only=True, action='store_true', help="Generates the clang compile_commands.json file during build. Implies --clean.")
26@cli.subcommand('Compile a QMK Firmware.')
27@automagic_keyboard
28@automagic_keymap
29def compile(cli):
30 """Compile a QMK Firmware.
31
32 If a Configurator export is supplied this command will create a new keymap, overwriting an existing keymap if one exists.
33
34 If a keyboard and keymap are provided this command will build a firmware based on that.
35 """
36
37 # If we've received `-kb all`, reroute it to mass-compile.
38 if is_all_keyboards(cli.args.keyboard):
39 from .mass_compile import mass_compile
40 cli.args.builds = []
41 cli.args.filter = []
42 cli.config.mass_compile.keymap = cli.config.compile.keymap
43 cli.config.mass_compile.parallel = cli.config.compile.parallel
44 cli.args.no_temp = False
45 return mass_compile(cli)
46
47 # If we've received `-km all`, reroute it to mass-compile.
48 if cli.args.keymap == 'all':
49 from .mass_compile import mass_compile
50 cli.args.builds = [f'{cli.config.compile.keyboard}:all']
51 cli.args.filter = []
52 cli.config.mass_compile.keymap = None
53 cli.config.mass_compile.parallel = cli.config.compile.parallel
54 cli.args.no_temp = False
55 return mass_compile(cli)
56
57 # Build the environment vars
58 envs = build_environment(cli.args.env)
59
60 # Handler for the build target
61 target = None
62
63 if cli.args.filename:
64 # if we were given a filename, assume we have a json build target
65 target = JsonKeymapBuildTarget(cli.args.filename)
66
67 elif cli.config.compile.keyboard and cli.config.compile.keymap:
68 # if we got a keyboard and keymap, attempt to find it
69 if not locate_keymap(cli.config.compile.keyboard, cli.config.compile.keymap):
70 cli.log.error('Invalid keymap argument.')
71 cli.print_help()
72 return False
73
74 # If we got here, then we have a valid keyboard and keymap for a build target
75 target = KeyboardKeymapBuildTarget(cli.config.compile.keyboard, cli.config.compile.keymap)
76
77 if not target:
78 cli.log.error('You must supply a configurator export, both `--keyboard` and `--keymap`, or be in a directory for a keyboard or keymap.')
79 cli.print_help()
80 return False
81
82 target.configure(parallel=cli.config.compile.parallel, clean=cli.args.clean, compiledb=cli.args.compiledb)
83 return target.compile(cli.args.target, dry_run=cli.args.dry_run, **envs)
diff --git a/lib/python/qmk/cli/docs.py b/lib/python/qmk/cli/docs.py
new file mode 100644
index 0000000000..da02ebf95e
--- /dev/null
+++ b/lib/python/qmk/cli/docs.py
@@ -0,0 +1,30 @@
1"""Serve QMK documentation locally
2"""
3import shutil
4from qmk.docs import prepare_docs_build_area, run_docs_command
5
6from milc import cli
7
8
9@cli.argument('-p', '--port', default=8936, type=int, help='Port number to use.')
10@cli.argument('-b', '--browser', action='store_true', help='Open the docs in the default browser.')
11@cli.subcommand('Run a local webserver for QMK documentation.', hidden=False if cli.config.user.developer else True)
12def docs(cli):
13 """Spin up a local HTTP server for the QMK docs.
14 """
15
16 if not shutil.which('doxygen'):
17 cli.log.error('doxygen is not installed. Please install it and try again.')
18 return
19
20 if not shutil.which('yarn'):
21 cli.log.error('yarn is not installed. Please install it and try again.')
22 return
23
24 if not prepare_docs_build_area(is_production=False):
25 return False
26
27 cmd = ['docs:dev', '--port', f'{cli.args.port}']
28 if cli.args.browser:
29 cmd.append('--open')
30 run_docs_command('run', cmd)
diff --git a/lib/python/qmk/cli/doctor/__init__.py b/lib/python/qmk/cli/doctor/__init__.py
new file mode 100755
index 0000000000..272e042023
--- /dev/null
+++ b/lib/python/qmk/cli/doctor/__init__.py
@@ -0,0 +1,5 @@
1"""QMK Doctor
2
3Check out the user's QMK environment and make sure it's ready to compile.
4"""
5from .main import doctor
diff --git a/lib/python/qmk/cli/doctor/check.py b/lib/python/qmk/cli/doctor/check.py
new file mode 100644
index 0000000000..a717bcb591
--- /dev/null
+++ b/lib/python/qmk/cli/doctor/check.py
@@ -0,0 +1,285 @@
1"""Check for specific programs.
2"""
3from enum import Enum
4import shutil
5from subprocess import DEVNULL, TimeoutExpired
6from tempfile import TemporaryDirectory
7from pathlib import Path
8
9from milc import cli
10from qmk import submodules
11from qmk.commands import find_make
12
13
14class CheckStatus(Enum):
15 OK = 1
16 WARNING = 2
17 ERROR = 3
18
19
20WHICH_MAKE = Path(find_make()).name
21
22ESSENTIAL_BINARIES = {
23 WHICH_MAKE: {},
24 'git': {},
25 'dos2unix': {},
26 'diff': {},
27 'dfu-programmer': {},
28 'avrdude': {},
29 'dfu-util': {},
30 'avr-gcc': {
31 'version_arg': '-dumpversion'
32 },
33 'arm-none-eabi-gcc': {
34 'version_arg': '-dumpversion'
35 },
36}
37
38
39def _check_make_version():
40 last_line = ESSENTIAL_BINARIES[WHICH_MAKE]['output'].split('\n')[0]
41 version_number = last_line.split()[2]
42 cli.log.info('Found %s version %s', WHICH_MAKE, version_number)
43
44 return CheckStatus.OK
45
46
47def _check_git_version():
48 last_line = ESSENTIAL_BINARIES['git']['output'].split('\n')[0]
49 version_number = last_line.split()[2]
50 cli.log.info('Found git version %s', version_number)
51
52 return CheckStatus.OK
53
54
55def _check_dos2unix_version():
56 last_line = ESSENTIAL_BINARIES['dos2unix']['output'].split('\n')[0]
57 version_number = last_line.split()[1]
58 cli.log.info('Found dos2unix version %s', version_number)
59
60 return CheckStatus.OK
61
62
63def _check_diff_version():
64 last_line = ESSENTIAL_BINARIES['diff']['output'].split('\n')[0]
65 if 'Apple diff' in last_line:
66 version_number = last_line
67 else:
68 version_number = last_line.split()[3]
69 cli.log.info('Found diff version %s', version_number)
70
71 return CheckStatus.OK
72
73
74def _check_arm_gcc_version():
75 """Returns True if the arm-none-eabi-gcc version is not known to cause problems.
76 """
77 version_number = ESSENTIAL_BINARIES['arm-none-eabi-gcc']['output'].strip()
78 cli.log.info('Found arm-none-eabi-gcc version %s', version_number)
79
80 # Right now all known ARM versions are ok, so check that it can produce binaries
81 return _check_arm_gcc_installation()
82
83
84def _check_arm_gcc_installation():
85 """Returns OK if the arm-none-eabi-gcc is fully installed and can produce binaries.
86 """
87 with TemporaryDirectory() as temp_dir:
88 temp_in = Path(temp_dir) / 'test.c'
89 temp_out = Path(temp_dir) / 'test.elf'
90
91 temp_in.write_text('#include <newlib.h>\nint main() { return __NEWLIB__ * __NEWLIB_MINOR__ * __NEWLIB_PATCHLEVEL__; }', encoding='utf-8')
92
93 args = ['arm-none-eabi-gcc', '-mcpu=cortex-m0', '-mthumb', '-mno-thumb-interwork', '--specs=nosys.specs', '--specs=nano.specs', '-x', 'c', '-o', str(temp_out), str(temp_in)]
94 result = cli.run(args, stdout=None, stderr=None)
95 if result.returncode == 0:
96 cli.log.info('Successfully compiled using arm-none-eabi-gcc')
97 else:
98 cli.log.error(f'Failed to compile a simple program with arm-none-eabi-gcc, return code {result.returncode}')
99 cli.log.error(f'Command: {" ".join(args)}')
100 return CheckStatus.ERROR
101
102 args = ['arm-none-eabi-size', str(temp_out)]
103 result = cli.run(args, stdout=None, stderr=None)
104 if result.returncode == 0:
105 cli.log.info('Successfully tested arm-none-eabi-binutils using arm-none-eabi-size')
106 else:
107 cli.log.error(f'Failed to execute arm-none-eabi-size, perhaps corrupt arm-none-eabi-binutils, return code {result.returncode}')
108 cli.log.error(f'Command: {" ".join(args)}')
109 return CheckStatus.ERROR
110
111 return CheckStatus.OK
112
113
114def _check_avr_gcc_version():
115 """Returns True if the avr-gcc version is not known to cause problems.
116 """
117 version_number = ESSENTIAL_BINARIES['avr-gcc']['output'].strip()
118 cli.log.info('Found avr-gcc version %s', version_number)
119
120 # Right now all known AVR versions are ok, so check that it can produce binaries
121 return _check_avr_gcc_installation()
122
123
124def _check_avr_gcc_installation():
125 """Returns OK if the avr-gcc is fully installed and can produce binaries.
126 """
127 with TemporaryDirectory() as temp_dir:
128 temp_in = Path(temp_dir) / 'test.c'
129 temp_out = Path(temp_dir) / 'test.elf'
130
131 temp_in.write_text('int main() { return 0; }', encoding='utf-8')
132
133 args = ['avr-gcc', '-mmcu=atmega32u4', '-x', 'c', '-o', str(temp_out), str(temp_in)]
134 result = cli.run(args, stdout=None, stderr=None)
135 if result.returncode == 0:
136 cli.log.info('Successfully compiled using avr-gcc')
137 else:
138 cli.log.error(f'Failed to compile a simple program with avr-gcc, return code {result.returncode}')
139 cli.log.error(f'Command: {" ".join(args)}')
140 return CheckStatus.ERROR
141
142 args = ['avr-size', str(temp_out)]
143 result = cli.run(args, stdout=None, stderr=None)
144 if result.returncode == 0:
145 cli.log.info('Successfully tested avr-binutils using avr-size')
146 else:
147 cli.log.error(f'Failed to execute avr-size, perhaps corrupt avr-binutils, return code {result.returncode}')
148 cli.log.error(f'Command: {" ".join(args)}')
149 return CheckStatus.ERROR
150
151 return CheckStatus.OK
152
153
154def _check_avrdude_version():
155 lines = ESSENTIAL_BINARIES['avrdude']['output'].split('\n')
156 # avrdude version text is currently not translated, however we fall back to old behaviour of assuming a line
157 version_line = next((line for line in lines if 'version' in line), lines[-2])
158 version_number = version_line.split()[2][:-1]
159 cli.log.info('Found avrdude version %s', version_number)
160
161 return CheckStatus.OK
162
163
164def _check_dfu_util_version():
165 first_line = ESSENTIAL_BINARIES['dfu-util']['output'].split('\n')[0]
166 version_number = first_line.split()[1]
167 cli.log.info('Found dfu-util version %s', version_number)
168
169 return CheckStatus.OK
170
171
172def _check_dfu_programmer_version():
173 first_line = ESSENTIAL_BINARIES['dfu-programmer']['output'].split('\n')[0]
174 version_number = first_line.split()[1]
175 cli.log.info('Found dfu-programmer version %s', version_number)
176
177 return CheckStatus.OK
178
179
180def check_binaries():
181 """Iterates through ESSENTIAL_BINARIES and tests them.
182 """
183 ok = CheckStatus.OK
184 missing_from_path = []
185
186 for binary in sorted(ESSENTIAL_BINARIES):
187 try:
188 if not is_in_path(binary):
189 ok = CheckStatus.ERROR
190 missing_from_path.append(binary)
191 elif not is_executable(binary):
192 ok = CheckStatus.ERROR
193 except TimeoutExpired:
194 cli.log.debug('Timeout checking %s', binary)
195 if ok != CheckStatus.ERROR:
196 ok = CheckStatus.WARNING
197
198 if missing_from_path:
199 location_noun = 'its location' if len(missing_from_path) == 1 else 'their locations'
200 cli.log.error('{fg_red}' + ', '.join(missing_from_path) + f' may need to be installed, or {location_noun} added to your path.')
201
202 return ok
203
204
205def check_binary_versions():
206 """Check the versions of ESSENTIAL_BINARIES
207 """
208 checks = {
209 WHICH_MAKE: _check_make_version,
210 'git': _check_git_version,
211 'dos2unix': _check_dos2unix_version,
212 'diff': _check_diff_version,
213 'arm-none-eabi-gcc': _check_arm_gcc_version,
214 'avr-gcc': _check_avr_gcc_version,
215 'avrdude': _check_avrdude_version,
216 'dfu-util': _check_dfu_util_version,
217 'dfu-programmer': _check_dfu_programmer_version,
218 }
219
220 versions = []
221 for binary in sorted(ESSENTIAL_BINARIES):
222 if 'output' not in ESSENTIAL_BINARIES[binary]:
223 cli.log.warning('Unknown version for %s', binary)
224 versions.append(CheckStatus.WARNING)
225 continue
226
227 check = checks[binary]
228 versions.append(check())
229 return versions
230
231
232def check_submodules():
233 """Iterates through all submodules to make sure they're cloned and up to date.
234 """
235 for submodule in submodules.status().values():
236 if submodule['status'] is None:
237 return CheckStatus.ERROR
238 elif not submodule['status']:
239 return CheckStatus.WARNING
240
241 return CheckStatus.OK
242
243
244def is_in_path(command):
245 """Returns True if command is found in the path.
246 """
247 if shutil.which(command) is None:
248 cli.log.error("{fg_red}Can't find %s in your path.", command)
249 return False
250 return True
251
252
253def is_executable(command):
254 """Returns True if command can be executed.
255 """
256 # Make sure the command can be executed
257 version_arg = ESSENTIAL_BINARIES[command].get('version_arg', '--version')
258 check = cli.run([command, version_arg], combined_output=True, stdin=DEVNULL, timeout=5)
259
260 ESSENTIAL_BINARIES[command]['output'] = check.stdout
261
262 if check.returncode in [0, 1]: # Older versions of dfu-programmer exit 1
263 cli.log.debug('Found {fg_cyan}%s', command)
264 return True
265
266 cli.log.error("{fg_red}Can't run `%s %s`", command, version_arg)
267 return False
268
269
270def release_info(file='/etc/os-release'):
271 """Parse release info to dict
272 """
273 ret = {}
274 try:
275 with open(file) as f:
276 for line in f:
277 if '=' in line:
278 key, value = map(str.strip, line.split('=', 1))
279 if value.startswith('"') and value.endswith('"'):
280 value = value[1:-1]
281 ret[key] = value
282 except (PermissionError, FileNotFoundError):
283 pass
284
285 return ret
diff --git a/lib/python/qmk/cli/doctor/linux.py b/lib/python/qmk/cli/doctor/linux.py
new file mode 100644
index 0000000000..c99cc6baea
--- /dev/null
+++ b/lib/python/qmk/cli/doctor/linux.py
@@ -0,0 +1,155 @@
1"""OS-specific functions for: Linux
2"""
3import platform
4import shutil
5from pathlib import Path
6
7from milc import cli
8
9from qmk.constants import QMK_FIRMWARE, BOOTLOADER_VIDS_PIDS
10from .check import CheckStatus, release_info
11
12
13def _is_wsl():
14 return 'microsoft' in platform.uname().release.lower()
15
16
17def _udev_rule(vid, pid=None, *args):
18 """ Helper function that return udev rules
19 """
20 rule = ""
21 if pid:
22 rule = 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="%s", ATTRS{idProduct}=="%s", TAG+="uaccess"' % (
23 vid,
24 pid,
25 )
26 else:
27 rule = 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="%s", TAG+="uaccess"' % vid
28 if args:
29 rule = ', '.join([rule, *args])
30 return rule
31
32
33def _generate_desired_rules(bootloader_vids_pids):
34 rules = dict()
35 for bl in bootloader_vids_pids.keys():
36 rules[bl] = set()
37 for vid_pid in bootloader_vids_pids[bl]:
38 if bl == 'caterina' or bl == 'md-boot':
39 rules[bl].add(_udev_rule(vid_pid[0], vid_pid[1], 'ENV{ID_MM_DEVICE_IGNORE}="1"'))
40 else:
41 rules[bl].add(_udev_rule(vid_pid[0], vid_pid[1]))
42 return rules
43
44
45def _deprecated_udev_rule(vid, pid=None):
46 """ Helper function that return udev rules
47
48 Note: these are no longer the recommended rules, this is just used to check for them
49 """
50 if pid:
51 return 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="%s", ATTRS{idProduct}=="%s", MODE:="0666"' % (vid, pid)
52 else:
53 return 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="%s", MODE:="0666"' % vid
54
55
56def check_udev_rules():
57 """Make sure the udev rules look good.
58 """
59 rc = CheckStatus.OK
60 udev_dirs = [
61 Path("/usr/lib/udev/rules.d/"),
62 Path("/usr/local/lib/udev/rules.d/"),
63 Path("/run/udev/rules.d/"),
64 Path("/etc/udev/rules.d/"),
65 ]
66
67 desired_rules = _generate_desired_rules(BOOTLOADER_VIDS_PIDS)
68
69 # These rules are no longer recommended, only use them to check for their presence.
70 deprecated_rules = {
71 'atmel-dfu': {_deprecated_udev_rule("03eb", "2ff4"), _deprecated_udev_rule("03eb", "2ffb"), _deprecated_udev_rule("03eb", "2ff0")},
72 'kiibohd': {_deprecated_udev_rule("1c11")},
73 'stm32': {_deprecated_udev_rule("1eaf", "0003"), _deprecated_udev_rule("0483", "df11")},
74 'bootloadhid': {_deprecated_udev_rule("16c0", "05df")},
75 'caterina': {'ATTRS{idVendor}=="2a03", ENV{ID_MM_DEVICE_IGNORE}="1"', 'ATTRS{idVendor}=="2341", ENV{ID_MM_DEVICE_IGNORE}="1"'},
76 'tmk': {_deprecated_udev_rule("feed")}
77 }
78
79 if any(udev_dir.exists() for udev_dir in udev_dirs):
80 udev_rules = [rule_file for udev_dir in udev_dirs for rule_file in udev_dir.glob('*.rules')]
81 current_rules = set()
82
83 # Collect all rules from the config files
84 for rule_file in udev_rules:
85 try:
86 for line in rule_file.read_text(encoding='utf-8').split('\n'):
87 line = line.strip()
88 if not line.startswith("#") and len(line):
89 current_rules.add(line)
90 except (PermissionError, FileNotFoundError):
91 cli.log.debug("Failed to read: %s", rule_file)
92
93 # Check if the desired rules are among the currently present rules
94 for bootloader, rules in desired_rules.items():
95 if not rules.issubset(current_rules):
96 deprecated_rule = deprecated_rules.get(bootloader)
97 if deprecated_rule and deprecated_rule.issubset(current_rules):
98 cli.log.warning("{fg_yellow}Found old, deprecated udev rules for '%s' boards. The new rules on https://docs.qmk.fm/#/faq_build?id=linux-udev-rules offer better security with the same functionality.", bootloader)
99 else:
100 # For caterina, check if ModemManager is running
101 if bootloader == "caterina" and check_modem_manager():
102 cli.log.warning("{fg_yellow}Detected ModemManager without the necessary udev rules. Please either disable it or set the appropriate udev rules if you are using a Pro Micro.")
103
104 rc = CheckStatus.WARNING
105 cli.log.warning("{fg_yellow}Missing or outdated udev rules for '%s' boards. Run 'sudo cp %s/util/udev/50-qmk.rules /etc/udev/rules.d/'.", bootloader, QMK_FIRMWARE)
106
107 else:
108 cli.log.warning("{fg_yellow}Can't find udev rules, skipping udev rule checking...")
109 cli.log.debug("Checked directories: %s", ', '.join(str(udev_dir) for udev_dir in udev_dirs))
110
111 return rc
112
113
114def check_systemd():
115 """Check if it's a systemd system
116 """
117 return bool(shutil.which("systemctl"))
118
119
120def check_modem_manager():
121 """Returns True if ModemManager is running.
122
123 """
124 if check_systemd():
125 mm_check = cli.run(["systemctl", "--quiet", "is-active", "ModemManager.service"], timeout=10)
126 if mm_check.returncode == 0:
127 return True
128 else:
129 """(TODO): Add check for non-systemd systems
130 """
131 return False
132
133
134def os_test_linux():
135 """Run the Linux specific tests.
136 """
137 info = release_info()
138 release_id = info.get('PRETTY_NAME', info.get('ID', 'Unknown'))
139 plat = 'WSL, ' if _is_wsl() else ''
140
141 cli.log.info(f"Detected {{fg_cyan}}Linux ({plat}{release_id}){{fg_reset}}.")
142
143 # Don't bother with udev on WSL, for now
144 if _is_wsl():
145 # https://github.com/microsoft/WSL/issues/4197
146 if QMK_FIRMWARE.as_posix().startswith("/mnt"):
147 cli.log.warning("I/O performance on /mnt may be extremely slow.")
148 return CheckStatus.WARNING
149
150 else:
151 rc = check_udev_rules()
152 if rc != CheckStatus.OK:
153 return rc
154
155 return CheckStatus.OK
diff --git a/lib/python/qmk/cli/doctor/macos.py b/lib/python/qmk/cli/doctor/macos.py
new file mode 100644
index 0000000000..5d088c9492
--- /dev/null
+++ b/lib/python/qmk/cli/doctor/macos.py
@@ -0,0 +1,13 @@
1import platform
2
3from milc import cli
4
5from .check import CheckStatus
6
7
8def os_test_macos():
9 """Run the Mac specific tests.
10 """
11 cli.log.info("Detected {fg_cyan}macOS %s (%s){fg_reset}.", platform.mac_ver()[0], 'Apple Silicon' if platform.processor() == 'arm' else 'Intel')
12
13 return CheckStatus.OK
diff --git a/lib/python/qmk/cli/doctor/main.py b/lib/python/qmk/cli/doctor/main.py
new file mode 100755
index 0000000000..45667e8ce2
--- /dev/null
+++ b/lib/python/qmk/cli/doctor/main.py
@@ -0,0 +1,240 @@
1"""QMK Doctor
2
3Check out the user's QMK environment and make sure it's ready to compile.
4"""
5import platform
6
7from milc import cli
8from milc.questions import yesno
9
10from qmk import submodules
11from qmk.constants import QMK_FIRMWARE, QMK_FIRMWARE_UPSTREAM, QMK_USERSPACE, HAS_QMK_USERSPACE
12from .check import CheckStatus, check_binaries, check_binary_versions, check_submodules
13from qmk.git import git_check_repo, git_get_branch, git_get_tag, git_get_last_log_entry, git_get_common_ancestor, git_is_dirty, git_get_remotes, git_check_deviation
14from qmk.commands import in_virtualenv
15from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError
16
17
18def distrib_tests():
19 def _load_kvp_file(file):
20 """Load a simple key=value file into a dictionary
21 """
22 vars = {}
23 with open(file, 'r') as f:
24 for line in f:
25 if '=' in line:
26 key, value = line.split('=', 1)
27 vars[key.strip()] = value.strip()
28 return vars
29
30 def _parse_toolchain_release_file(file):
31 """Parse the QMK toolchain release info file
32 """
33 try:
34 vars = _load_kvp_file(file)
35 return f'{vars.get("TOOLCHAIN_HOST", "unknown")}:{vars.get("TOOLCHAIN_TARGET", "unknown")}:{vars.get("COMMIT_HASH", "unknown")}'
36 except Exception as e:
37 cli.log.warning('Error reading QMK toolchain release info file: %s', e)
38 return f'Unknown toolchain release info file: {file}'
39
40 def _parse_flashutils_release_file(file):
41 """Parse the QMK flashutils release info file
42 """
43 try:
44 vars = _load_kvp_file(file)
45 return f'{vars.get("FLASHUTILS_HOST", "unknown")}:{vars.get("COMMIT_HASH", "unknown")}'
46 except Exception as e:
47 cli.log.warning('Error reading QMK flashutils release info file: %s', e)
48 return f'Unknown flashutils release info file: {file}'
49
50 try:
51 from qmk.cli import QMK_DISTRIB_DIR
52 if (QMK_DISTRIB_DIR / 'etc').exists():
53 cli.log.info('Found QMK tools distribution directory: {fg_cyan}%s', QMK_DISTRIB_DIR)
54
55 toolchains = [_parse_toolchain_release_file(file) for file in (QMK_DISTRIB_DIR / 'etc').glob('toolchain_release_*')]
56 if len(toolchains) > 0:
57 cli.log.info('Found QMK toolchains: {fg_cyan}%s', ', '.join(toolchains))
58 else:
59 cli.log.warning('No QMK toolchains manifest found.')
60
61 flashutils = [_parse_flashutils_release_file(file) for file in (QMK_DISTRIB_DIR / 'etc').glob('flashutils_release_*')]
62 if len(flashutils) > 0:
63 cli.log.info('Found QMK flashutils: {fg_cyan}%s', ', '.join(flashutils))
64 else:
65 cli.log.warning('No QMK flashutils manifest found.')
66 except ImportError:
67 cli.log.info('QMK tools distribution not found.')
68
69 return CheckStatus.OK
70
71
72def os_tests():
73 """Determine our OS and run platform specific tests
74 """
75 platform_id = platform.platform().lower()
76
77 if 'darwin' in platform_id or 'macos' in platform_id:
78 from .macos import os_test_macos
79 return os_test_macos()
80 elif 'linux' in platform_id:
81 from .linux import os_test_linux
82 return os_test_linux()
83 elif 'windows' in platform_id:
84 from .windows import os_test_windows
85 return os_test_windows()
86 else:
87 cli.log.warning('Unsupported OS detected: %s', platform_id)
88 return CheckStatus.WARNING
89
90
91def git_tests():
92 """Run Git-related checks
93 """
94 status = CheckStatus.OK
95
96 # Make sure our QMK home is a Git repo
97 git_ok = git_check_repo()
98 if not git_ok:
99 cli.log.warning("{fg_yellow}QMK home does not appear to be a Git repository! (no .git folder)")
100 status = CheckStatus.WARNING
101 else:
102 git_branch = git_get_branch()
103 if git_branch:
104 cli.log.info('Git branch: %s', git_branch)
105
106 repo_version = git_get_tag()
107 if repo_version:
108 cli.log.info('Repo version: %s', repo_version)
109
110 git_dirty = git_is_dirty()
111 if git_dirty:
112 cli.log.warning('{fg_yellow}Git has unstashed/uncommitted changes.')
113 status = CheckStatus.WARNING
114 git_remotes = git_get_remotes()
115 if 'upstream' not in git_remotes.keys() or QMK_FIRMWARE_UPSTREAM not in git_remotes['upstream'].get('url', ''):
116 cli.log.warning('{fg_yellow}The official repository does not seem to be configured as git remote "upstream".')
117 status = CheckStatus.WARNING
118 else:
119 git_deviation = git_check_deviation(git_branch)
120 if git_branch in ['master', 'develop'] and git_deviation:
121 cli.log.warning('{fg_yellow}The local "%s" branch contains commits not found in the upstream branch.', git_branch)
122 status = CheckStatus.WARNING
123 for branch in [git_branch, 'upstream/master', 'upstream/develop']:
124 cli.log.info('- Latest %s: %s', branch, git_get_last_log_entry(branch))
125 for branch in ['upstream/master', 'upstream/develop']:
126 cli.log.info('- Common ancestor with %s: %s', branch, git_get_common_ancestor(branch, 'HEAD'))
127
128 return status
129
130
131def output_submodule_status():
132 """Prints out information related to the submodule status.
133 """
134 cli.log.info('Submodule status:')
135 sub_status = submodules.status()
136 for s in sub_status.keys():
137 sub_info = sub_status[s]
138 if 'name' in sub_info:
139 sub_name = sub_info['name']
140 sub_shorthash = sub_info['shorthash'] if 'shorthash' in sub_info else ''
141 sub_describe = sub_info['describe'] if 'describe' in sub_info else ''
142 sub_last_log_timestamp = sub_info['last_log_timestamp'] if 'last_log_timestamp' in sub_info else ''
143 if sub_last_log_timestamp != '':
144 cli.log.info(f'- {sub_name}: {sub_last_log_timestamp} -- {sub_describe} ({sub_shorthash})')
145 else:
146 cli.log.error(f'- {sub_name}: <<< missing or unknown >>>')
147
148
149def userspace_tests(qmk_firmware):
150 if qmk_firmware:
151 cli.log.info(f'QMK home: {{fg_cyan}}{qmk_firmware}')
152
153 for path in qmk_userspace_paths():
154 try:
155 qmk_userspace_validate(path)
156 cli.log.info(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_green}}Valid `qmk.json`')
157 except FileNotFoundError:
158 cli.log.warning(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_red}}Missing `qmk.json`')
159 except UserspaceValidationError as err:
160 cli.log.warning(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_red}}Invalid `qmk.json`')
161 cli.log.warning(f' -- {{fg_cyan}}{path}/qmk.json{{fg_reset}} validation error: {err}')
162
163 if QMK_USERSPACE is not None:
164 cli.log.info(f'QMK userspace: {{fg_cyan}}{QMK_USERSPACE}')
165 cli.log.info(f'Userspace enabled: {{fg_cyan}}{HAS_QMK_USERSPACE}')
166
167
168@cli.argument('-y', '--yes', action='store_true', arg_only=True, help='Answer yes to all questions.')
169@cli.argument('-n', '--no', action='store_true', arg_only=True, help='Answer no to all questions.')
170@cli.subcommand('Basic QMK environment checks')
171def doctor(cli):
172 """Basic QMK environment checks.
173
174 This is currently very simple, it just checks that all the expected binaries are on your system.
175
176 TODO(unclaimed):
177 * [ ] Compile a trivial program with each compiler
178 """
179 cli.log.info('QMK Doctor is checking your environment.')
180 cli.log.info('Python version: %s', platform.python_version())
181 cli.log.info('CLI version: %s', cli.version)
182 cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE)
183
184 status = os_status = os_tests()
185 distrib_tests()
186
187 userspace_tests(None)
188
189 git_status = git_tests()
190
191 if git_status == CheckStatus.ERROR or (os_status == CheckStatus.OK and git_status == CheckStatus.WARNING):
192 status = git_status
193
194 if in_virtualenv():
195 cli.log.info('CLI installed in virtualenv.')
196
197 # Make sure the basic CLI tools we need are available and can be executed.
198 bin_ok = check_binaries()
199 if bin_ok == CheckStatus.OK:
200 cli.log.info('All dependencies are installed.')
201 elif bin_ok == CheckStatus.WARNING:
202 cli.log.warning('Issues encountered while checking dependencies.')
203 else:
204 status = CheckStatus.ERROR
205
206 # Make sure the tools are at the correct version
207 ver_ok = check_binary_versions()
208 if CheckStatus.ERROR in ver_ok:
209 status = CheckStatus.ERROR
210 elif CheckStatus.WARNING in ver_ok and status == CheckStatus.OK:
211 status = CheckStatus.WARNING
212
213 # Check out the QMK submodules
214 sub_ok = check_submodules()
215 if sub_ok == CheckStatus.OK:
216 cli.log.info('Submodules are up to date.')
217 else:
218 if git_check_repo() and yesno('Would you like to clone the submodules?', default=True):
219 submodules.update()
220 sub_ok = check_submodules()
221
222 if sub_ok == CheckStatus.ERROR:
223 status = CheckStatus.ERROR
224 elif sub_ok == CheckStatus.WARNING and status == CheckStatus.OK:
225 status = CheckStatus.WARNING
226
227 output_submodule_status()
228
229 # Report a summary of our findings to the user
230 if status == CheckStatus.OK:
231 cli.log.info('{fg_green}QMK is ready to go')
232 return 0
233 elif status == CheckStatus.WARNING:
234 cli.log.info('{fg_yellow}QMK is ready to go, but minor problems were found')
235 return 1
236 else:
237 cli.log.info('{fg_red}Major problems detected, please fix these problems before proceeding.{fg_reset}')
238 cli.log.info('{fg_blue}If you\'re missing dependencies, try following the instructions on: https://docs.qmk.fm/newbs_getting_started{fg_reset}')
239 cli.log.info('{fg_blue}Additionally, check out the FAQ (https://docs.qmk.fm/#/faq_build) or join the QMK Discord (https://discord.gg/qmk) for help.{fg_reset}')
240 return 2
diff --git a/lib/python/qmk/cli/doctor/windows.py b/lib/python/qmk/cli/doctor/windows.py
new file mode 100644
index 0000000000..26bb65374b
--- /dev/null
+++ b/lib/python/qmk/cli/doctor/windows.py
@@ -0,0 +1,20 @@
1import platform
2
3from milc import cli
4
5from .check import CheckStatus, release_info
6
7
8def os_test_windows():
9 """Run the Windows specific tests.
10 """
11 win32_ver = platform.win32_ver()
12 cli.log.info("Detected {fg_cyan}Windows %s (%s){fg_reset}.", win32_ver[0], win32_ver[1])
13
14 # MSYS really does not like "/" files - resolve manually
15 file = cli.run(['cygpath', '-m', '/etc/qmk-release']).stdout.strip()
16 qmk_distro_version = release_info(file).get('VERSION', None)
17 if qmk_distro_version:
18 cli.log.info('QMK MSYS version: %s', qmk_distro_version)
19
20 return CheckStatus.OK
diff --git a/lib/python/qmk/cli/find.py b/lib/python/qmk/cli/find.py
new file mode 100644
index 0000000000..7d8b1b066c
--- /dev/null
+++ b/lib/python/qmk/cli/find.py
@@ -0,0 +1,32 @@
1"""Command to search through all keyboards and keymaps for a given search criteria.
2"""
3import os
4from milc import cli
5from qmk.search import filter_help, search_keymap_targets
6from qmk.util import maybe_exit_config
7
8
9@cli.argument(
10 '-f',
11 '--filter',
12 arg_only=True,
13 action='append',
14 default=[],
15 help= # noqa: `format-python` and `pytest` don't agree here.
16 f"Filter the list of keyboards based on their info.json data. Accepts the formats key=value, function(key), or function(key,value), eg. 'features.rgblight=true'. Valid functions are {filter_help()}. May be passed multiple times; all filters need to match. Value may include wildcards such as '*' and '?'." # noqa: `format-python` and `pytest` don't agree here.
17)
18@cli.argument('-p', '--print', arg_only=True, action='append', default=[], help="For each matched target, print the value of the supplied info.json key. May be passed multiple times.")
19@cli.argument('-km', '--keymap', type=str, default='default', help="The keymap name to build. Default is 'default'.")
20@cli.subcommand('Find builds which match supplied search criteria.')
21def find(cli):
22 """Search through all keyboards and keymaps for a given search criteria.
23 """
24 os.environ.setdefault('SKIP_SCHEMA_VALIDATION', '1')
25 maybe_exit_config(should_exit=False, should_reraise=True)
26
27 targets = search_keymap_targets([('all', cli.config.find.keymap)], cli.args.filter)
28 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)):
29 print(f'{target}')
30
31 for key in cli.args.print:
32 print(f' {key}={target.dotty.get(key, None)}')
diff --git a/lib/python/qmk/cli/flash.py b/lib/python/qmk/cli/flash.py
new file mode 100644
index 0000000000..c570b49ebe
--- /dev/null
+++ b/lib/python/qmk/cli/flash.py
@@ -0,0 +1,116 @@
1"""Compile and flash QMK Firmware
2
3You can compile a keymap already in the repo or using a QMK Configurator export.
4A bootloader must be specified.
5"""
6from argcomplete.completers import FilesCompleter
7from pathlib import Path
8
9from milc import cli
10
11import qmk.path
12from qmk.decorators import automagic_keyboard, automagic_keymap
13from qmk.commands import build_environment
14from qmk.keyboard import keyboard_completer, keyboard_folder
15from qmk.keymap import keymap_completer, locate_keymap
16from qmk.flashers import flasher
17from qmk.build_targets import KeyboardKeymapBuildTarget, JsonKeymapBuildTarget
18
19
20def _list_bootloaders():
21 """Prints the available bootloaders listed in docs.qmk.fm.
22 """
23 cli.print_help()
24 cli.log.info('Here are the available bootloaders:')
25 cli.echo('\tavrdude')
26 cli.echo('\tbootloadhid')
27 cli.echo('\tdfu')
28 cli.echo('\tdfu-util')
29 cli.echo('\tmdloader')
30 cli.echo('\tst-flash')
31 cli.echo('\tst-link-cli')
32 cli.log.info('Enhanced variants for split keyboards:')
33 cli.echo('\tavrdude-split-left')
34 cli.echo('\tavrdude-split-right')
35 cli.echo('\tdfu-ee')
36 cli.echo('\tdfu-split-left')
37 cli.echo('\tdfu-split-right')
38 cli.echo('\tdfu-util-split-left')
39 cli.echo('\tdfu-util-split-right')
40 cli.echo('\tuf2-split-left')
41 cli.echo('\tuf2-split-right')
42 cli.echo('For more info, visit https://docs.qmk.fm/#/flashing')
43 return False
44
45
46def _flash_binary(filename, mcu):
47 """Try to flash binary firmware
48 """
49 cli.echo('Flashing binary firmware...\nPlease reset your keyboard into bootloader mode now!\nPress Ctrl-C to exit.\n')
50 try:
51 err, msg = flasher(mcu, filename)
52 if err:
53 cli.log.error(msg)
54 return False
55 except KeyboardInterrupt:
56 cli.log.info('Ctrl-C was pressed, exiting...')
57 return True
58
59
60@cli.argument('filename', nargs='?', arg_only=True, type=qmk.path.FileType('r'), completer=FilesCompleter('.json'), help='A configurator export JSON to be compiled and flashed or a pre-compiled binary firmware file (bin/hex) to be flashed.')
61@cli.argument('-b', '--bootloaders', action='store_true', help='List the available bootloaders.')
62@cli.argument('-bl', '--bootloader', default='flash', help='The flash command, corresponding to qmk\'s make options of bootloaders.')
63@cli.argument('-m', '--mcu', help='The MCU name. Required for HalfKay, HID, USBAspLoader and ISP flashing.')
64@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
65@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
66@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually build, just show the make command to be run.")
67@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
68@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.")
69@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
70@cli.subcommand('QMK Flash.')
71@automagic_keyboard
72@automagic_keymap
73def flash(cli):
74 """Compile and or flash QMK Firmware or keyboard/layout
75
76 If a binary firmware is supplied, try to flash that.
77
78 If a Configurator export is supplied this command will create a new keymap, overwriting an existing keymap if one exists.
79
80 If a keyboard and keymap are provided this command will build a firmware based on that.
81
82 If bootloader is omitted the make system will use the configured bootloader for that keyboard.
83 """
84 if cli.args.filename and isinstance(cli.args.filename, Path) and cli.args.filename.suffix in ['.bin', '.hex', '.uf2']:
85 return _flash_binary(cli.args.filename, cli.args.mcu)
86
87 if cli.args.bootloaders:
88 return _list_bootloaders()
89
90 # Build the environment vars
91 envs = build_environment(cli.args.env)
92
93 # Handler for the build target
94 target = None
95
96 if cli.args.filename:
97 # if we were given a filename, assume we have a json build target
98 target = JsonKeymapBuildTarget(cli.args.filename)
99
100 elif cli.config.flash.keyboard and cli.config.flash.keymap:
101 # if we got a keyboard and keymap, attempt to find it
102 if not locate_keymap(cli.config.flash.keyboard, cli.config.flash.keymap):
103 cli.log.error('Invalid keymap argument.')
104 cli.print_help()
105 return False
106
107 # If we got here, then we have a valid keyboard and keymap for a build target
108 target = KeyboardKeymapBuildTarget(cli.config.flash.keyboard, cli.config.flash.keymap)
109
110 if not target:
111 cli.log.error('You must supply a configurator export, both `--keyboard` and `--keymap`, or be in a directory for a keyboard or keymap.')
112 cli.print_help()
113 return False
114
115 target.configure(parallel=cli.config.flash.parallel, clean=cli.args.clean)
116 return target.compile(cli.args.bootloader, dry_run=cli.args.dry_run, **envs)
diff --git a/lib/python/qmk/cli/format/__init__.py b/lib/python/qmk/cli/format/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/format/__init__.py
diff --git a/lib/python/qmk/cli/format/c.py b/lib/python/qmk/cli/format/c.py
new file mode 100644
index 0000000000..65818155b0
--- /dev/null
+++ b/lib/python/qmk/cli/format/c.py
@@ -0,0 +1,138 @@
1"""Format C code according to QMK's style.
2"""
3from shutil import which
4from subprocess import CalledProcessError, DEVNULL, Popen, PIPE
5
6from argcomplete.completers import FilesCompleter
7from milc import cli
8
9from qmk.path import normpath
10from qmk.c_parse import c_source_files
11
12c_file_suffixes = ('c', 'h', 'cpp', 'hpp')
13core_dirs = ('drivers', 'quantum', 'tests', 'tmk_core', 'platforms', 'modules')
14ignored = ('tmk_core/protocol/usb_hid', 'platforms/chibios/boards')
15
16
17def is_relative_to(file, other):
18 """Provide similar behavior to PurePath.is_relative_to in Python > 3.9
19 """
20 return str(normpath(file).resolve()).startswith(str(normpath(other).resolve()))
21
22
23def find_clang_format():
24 """Returns the path to clang-format.
25 """
26 for clang_version in range(20, 6, -1):
27 binary = f'clang-format-{clang_version}'
28
29 if which(binary):
30 return binary
31
32 return 'clang-format'
33
34
35def find_diffs(files):
36 """Run clang-format and diff it against a file.
37 """
38 found_diffs = False
39
40 for file in files:
41 cli.log.debug('Checking for changes in %s', file)
42 clang_format = Popen([find_clang_format(), file], stdout=PIPE, stderr=PIPE, universal_newlines=True)
43 diff = cli.run(['diff', '-u', f'--label=a/{file}', f'--label=b/{file}', str(file), '-'], stdin=clang_format.stdout, capture_output=True)
44
45 if diff.returncode != 0:
46 print(diff.stdout)
47 found_diffs = True
48
49 return found_diffs
50
51
52def cformat_run(files):
53 """Spawn clang-format subprocess with proper arguments
54 """
55 # Determine which version of clang-format to use
56 clang_format = [find_clang_format(), '-i']
57
58 try:
59 cli.run([*clang_format, *map(str, files)], check=True, capture_output=False, stdin=DEVNULL)
60 cli.log.info('Successfully formatted the C code.')
61 return True
62
63 except CalledProcessError as e:
64 cli.log.error('Error formatting C code!')
65 cli.log.debug('%s exited with returncode %s', e.cmd, e.returncode)
66 cli.log.debug('STDOUT:')
67 cli.log.debug(e.stdout)
68 cli.log.debug('STDERR:')
69 cli.log.debug(e.stderr)
70 return False
71
72
73def filter_files(files, core_only=False):
74 """Yield only files to be formatted and skip the rest
75 """
76 files = list(map(normpath, filter(None, files)))
77
78 for file in files:
79 if core_only:
80 # The following statement checks each file to see if the file path is
81 # - in the core directories
82 # - not in the ignored directories
83 if not any(is_relative_to(file, i) for i in core_dirs) or any(is_relative_to(file, i) for i in ignored):
84 cli.log.debug("Skipping non-core file %s, as '--core-only' is used.", file)
85 continue
86
87 if file.suffix[1:] in c_file_suffixes:
88 yield file
89 else:
90 cli.log.debug('Skipping file %s', file)
91
92
93@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Flag only, don't automatically format.")
94@cli.argument('-b', '--base-branch', default='origin/master', help='Branch to compare to diffs to.')
95@cli.argument('-a', '--all-files', arg_only=True, action='store_true', help='Format all core files.')
96@cli.argument('--core-only', arg_only=True, action='store_true', help='Format core files only.')
97@cli.argument('files', nargs='*', arg_only=True, type=normpath, completer=FilesCompleter('.c'), help='Filename(s) to format.')
98@cli.subcommand("Format C code according to QMK's style.", hidden=False if cli.config.user.developer else True)
99def format_c(cli):
100 """Format C code according to QMK's style.
101 """
102 # Find the list of files to format
103 if cli.args.files:
104 files = list(filter_files(cli.args.files, cli.args.core_only))
105
106 if not files:
107 cli.log.error('No C files in filelist: %s', ', '.join(map(str, cli.args.files)))
108 exit(0)
109
110 if cli.args.all_files:
111 cli.log.warning('Filenames passed with -a, only formatting: %s', ','.join(map(str, files)))
112
113 elif cli.args.all_files:
114 all_files = c_source_files(core_dirs)
115 files = list(filter_files(all_files, True))
116
117 else:
118 git_diff_cmd = ['git', 'diff', '--name-only', cli.args.base_branch, *core_dirs]
119 git_diff = cli.run(git_diff_cmd, stdin=DEVNULL)
120
121 if git_diff.returncode != 0:
122 cli.log.error("Error running %s", git_diff_cmd)
123 print(git_diff.stderr)
124 return git_diff.returncode
125
126 changed_files = git_diff.stdout.strip().split('\n')
127 files = list(filter_files(changed_files, True))
128
129 # Sanity check
130 if not files:
131 cli.log.error('No changed files detected. Use "qmk format-c -a" to format all core files')
132 return False
133
134 # Run clang-format on the files we've found
135 if cli.args.dry_run:
136 return not find_diffs(files)
137 else:
138 return cformat_run(files)
diff --git a/lib/python/qmk/cli/format/json.py b/lib/python/qmk/cli/format/json.py
new file mode 100755
index 0000000000..61f5254184
--- /dev/null
+++ b/lib/python/qmk/cli/format/json.py
@@ -0,0 +1,118 @@
1"""JSON Formatting Script
2
3Spits out a JSON file formatted with one of QMK's formatters.
4"""
5import json
6
7from jsonschema import ValidationError
8from milc import cli
9
10from qmk.info import info_json
11from qmk.json_schema import json_load, validate
12from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder, UserspaceJSONEncoder, CommunityModuleJSONEncoder
13from qmk.path import normpath
14
15
16def _detect_json_format(file, json_data):
17 """Detect the format of a json file.
18 """
19 json_encoder = None
20 try:
21 validate(json_data, 'qmk.user_repo.v1_1')
22 json_encoder = UserspaceJSONEncoder
23 except ValidationError:
24 pass
25
26 if json_encoder is None:
27 try:
28 validate(json_data, 'qmk.user_repo.v1')
29 json_encoder = UserspaceJSONEncoder
30 except ValidationError:
31 pass
32
33 if json_encoder is None:
34 try:
35 validate(json_data, 'qmk.community_module.v1')
36 json_encoder = CommunityModuleJSONEncoder
37 except ValidationError:
38 pass
39
40 if json_encoder is None:
41 try:
42 validate(json_data, 'qmk.keyboard.v1')
43 json_encoder = InfoJSONEncoder
44 except ValidationError as e:
45 cli.log.warning('File %s did not validate as a keyboard info.json or userspace qmk.json:\n\t%s', file, e)
46 cli.log.info('Treating %s as a keymap file.', file)
47 json_encoder = KeymapJSONEncoder
48
49 return json_encoder
50
51
52def _get_json_encoder(file, json_data):
53 """Get the json encoder for a file.
54 """
55 json_encoder = None
56 if cli.args.format == 'auto':
57 json_encoder = _detect_json_format(file, json_data)
58 elif cli.args.format == 'keyboard':
59 json_encoder = InfoJSONEncoder
60 elif cli.args.format == 'keymap':
61 json_encoder = KeymapJSONEncoder
62 elif cli.args.format == 'userspace':
63 json_encoder = UserspaceJSONEncoder
64 elif cli.args.format == 'community_module':
65 json_encoder = CommunityModuleJSONEncoder
66 else:
67 # This should be impossible
68 cli.log.error('Unknown format: %s', cli.args.format)
69 return json_encoder
70
71
72@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
73@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap', 'userspace', 'community_module'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
74@cli.argument('-i', '--inplace', action='store_true', arg_only=True, help='If set, will operate in-place on the input file')
75@cli.argument('-p', '--print', action='store_true', arg_only=True, help='If set, will print the formatted json to stdout ')
76@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
77def format_json(cli):
78 """Format a json file.
79 """
80 json_data = json_load(cli.args.json_file)
81
82 json_encoder = _get_json_encoder(cli.args.json_file, json_data)
83 if json_encoder is None:
84 return False
85
86 if json_encoder == KeymapJSONEncoder and 'layout' in json_data:
87 # Attempt to format the keycodes.
88 layout = json_data['layout']
89 info_data = info_json(json_data['keyboard'])
90
91 if layout in info_data.get('layout_aliases', {}):
92 layout = json_data['layout'] = info_data['layout_aliases'][layout]
93
94 if layout in info_data.get('layouts'):
95 for layer_num, layer in enumerate(json_data['layers']):
96 current_layer = []
97 last_row = 0
98
99 for keymap_key, info_key in zip(layer, info_data['layouts'][layout]['layout']):
100 if last_row != info_key['y']:
101 current_layer.append('JSON_NEWLINE')
102 last_row = info_key['y']
103
104 current_layer.append(keymap_key)
105
106 json_data['layers'][layer_num] = current_layer
107
108 output = json.dumps(json_data, cls=json_encoder, sort_keys=True)
109
110 if cli.args.inplace:
111 with open(cli.args.json_file, 'w+', encoding='utf-8', newline='\n') as outfile:
112 outfile.write(output + '\n')
113
114 # Display the results if print was set
115 # We don't operate in-place by default, so also display to stdout
116 # if in-place is not set.
117 if cli.args.print or not cli.args.inplace:
118 print(output)
diff --git a/lib/python/qmk/cli/format/python.py b/lib/python/qmk/cli/format/python.py
new file mode 100755
index 0000000000..e7b545109f
--- /dev/null
+++ b/lib/python/qmk/cli/format/python.py
@@ -0,0 +1,70 @@
1"""Format python code according to QMK's style.
2"""
3from subprocess import CalledProcessError, DEVNULL
4
5from milc import cli
6
7from qmk.path import normpath
8
9py_file_suffixes = ('py',)
10py_dirs = ['lib/python', 'util/ci']
11
12
13def yapf_run(files):
14 edit = '--diff' if cli.args.dry_run else '--in-place'
15 yapf_cmd = ['yapf', '-vv', '--recursive', edit, *files]
16 try:
17 cli.run(yapf_cmd, check=True, capture_output=False, stdin=DEVNULL)
18 cli.log.info('Successfully formatted the python code.')
19
20 except CalledProcessError:
21 cli.log.error(f'Python code in {",".join(py_dirs)} incorrectly formatted!')
22 return False
23
24
25def filter_files(files):
26 """Yield only files to be formatted and skip the rest
27 """
28 files = list(map(normpath, filter(None, files)))
29 for file in files:
30 if file.suffix[1:] in py_file_suffixes:
31 yield file
32 else:
33 cli.log.debug('Skipping file %s', file)
34
35
36@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually format.")
37@cli.argument('-b', '--base-branch', default='origin/master', help='Branch to compare to diffs to.')
38@cli.argument('-a', '--all-files', arg_only=True, action='store_true', help='Format all files.')
39@cli.argument('files', nargs='*', arg_only=True, type=normpath, help='Filename(s) to format.')
40@cli.subcommand("Format python code according to QMK's style.", hidden=False if cli.config.user.developer else True)
41def format_python(cli):
42 """Format python code according to QMK's style.
43 """
44 # Find the list of files to format
45 if cli.args.files:
46 files = list(filter_files(cli.args.files))
47
48 if not files:
49 cli.log.error('No Python files in filelist: %s', ', '.join(map(str, cli.args.files)))
50 exit(0)
51
52 if cli.args.all_files:
53 cli.log.warning('Filenames passed with -a, only formatting: %s', ','.join(map(str, files)))
54
55 elif cli.args.all_files:
56 git_ls_cmd = ['git', 'ls-files', *py_dirs]
57 git_ls = cli.run(git_ls_cmd, stdin=DEVNULL)
58 files = list(filter_files(git_ls.stdout.split('\n')))
59
60 else:
61 git_diff_cmd = ['git', 'diff', '--name-only', cli.args.base_branch, *py_dirs]
62 git_diff = cli.run(git_diff_cmd, stdin=DEVNULL)
63 files = list(filter_files(git_diff.stdout.split('\n')))
64
65 # Sanity check
66 if not files:
67 cli.log.error('No changed files detected. Use "qmk format-python -a" to format all files')
68 return False
69
70 return yapf_run(files)
diff --git a/lib/python/qmk/cli/format/text.py b/lib/python/qmk/cli/format/text.py
new file mode 100644
index 0000000000..344631081b
--- /dev/null
+++ b/lib/python/qmk/cli/format/text.py
@@ -0,0 +1,57 @@
1"""Ensure text files have the proper line endings.
2"""
3from itertools import islice
4from subprocess import DEVNULL
5
6from milc import cli
7
8from qmk.path import normpath
9
10
11def _get_chunks(it, size):
12 """Break down a collection into smaller parts
13 """
14 it = iter(it)
15 return iter(lambda: tuple(islice(it, size)), ())
16
17
18def dos2unix_run(files):
19 """Spawn multiple dos2unix subprocess avoiding too long commands on formatting everything
20 """
21 for chunk in _get_chunks([normpath(file).as_posix() for file in files], 10):
22 dos2unix = cli.run(['dos2unix', *chunk])
23
24 if dos2unix.returncode:
25 return False
26
27
28@cli.argument('-b', '--base-branch', default='origin/master', help='Branch to compare to diffs to.')
29@cli.argument('-a', '--all-files', arg_only=True, action='store_true', help='Format all files.')
30@cli.argument('files', nargs='*', arg_only=True, type=normpath, help='Filename(s) to format.')
31@cli.subcommand("Ensure text files have the proper line endings.", hidden=True)
32def format_text(cli):
33 """Ensure text files have the proper line endings.
34 """
35 # Find the list of files to format
36 if cli.args.files:
37 files = list(cli.args.files)
38
39 if cli.args.all_files:
40 cli.log.warning('Filenames passed with -a, only formatting: %s', ','.join(map(str, files)))
41
42 elif cli.args.all_files:
43 git_ls_cmd = ['git', 'ls-files']
44 git_ls = cli.run(git_ls_cmd, stdin=DEVNULL)
45 files = list(filter(None, git_ls.stdout.split('\n')))
46
47 else:
48 git_diff_cmd = ['git', 'diff', '--name-only', cli.args.base_branch]
49 git_diff = cli.run(git_diff_cmd, stdin=DEVNULL)
50 files = list(filter(None, git_diff.stdout.split('\n')))
51
52 # Sanity check
53 if not files:
54 cli.log.error('No changed files detected. Use "qmk format-text -a" to format all files')
55 return False
56
57 return dos2unix_run(files)
diff --git a/lib/python/qmk/cli/generate/__init__.py b/lib/python/qmk/cli/generate/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/generate/__init__.py
diff --git a/lib/python/qmk/cli/generate/api.py b/lib/python/qmk/cli/generate/api.py
new file mode 100755
index 0000000000..7f7c05f6e2
--- /dev/null
+++ b/lib/python/qmk/cli/generate/api.py
@@ -0,0 +1,204 @@
1"""This script automates the generation of the QMK API data.
2"""
3from pathlib import Path
4import shutil
5import json
6
7from milc import cli
8
9import qmk.path
10from qmk.datetime import current_datetime
11from qmk.info import info_json
12from qmk.json_schema import json_load
13from qmk.keymap import list_keymaps
14from qmk.keyboard import find_readme, list_keyboards, keyboard_alias_definitions
15from qmk.keycodes import load_spec, list_versions, list_languages
16
17DATA_PATH = Path('data')
18TEMPLATE_PATH = DATA_PATH / 'templates/api/'
19BUILD_API_PATH = Path('.build/api_data/')
20
21
22def _list_constants(output_folder):
23 """Produce a map of available constants
24 """
25 ret = {}
26 for file in (output_folder / 'constants').glob('**/*_[0-9].[0-9].[0-9].json'):
27 name, version = file.stem.rsplit('_', 1)
28 if name not in ret:
29 ret[name] = []
30 ret[name].append(version)
31
32 # Ensure content is sorted
33 for name in ret:
34 ret[name] = sorted(ret[name])
35
36 return ret
37
38
39def _resolve_keycode_specs(output_folder):
40 """To make it easier for consumers, publish pre-merged spec files
41 """
42 for version in list_versions():
43 overall = load_spec(version)
44
45 output_file = output_folder / f'constants/keycodes_{version}.json'
46 output_file.write_text(json.dumps(overall, separators=(',', ':')), encoding='utf-8')
47
48 for lang in list_languages():
49 for version in list_versions(lang):
50 overall = load_spec(version, lang)
51
52 output_file = output_folder / f'constants/keycodes_{lang}_{version}.json'
53 output_file.write_text(json.dumps(overall, separators=(',', ':')), encoding='utf-8')
54
55 # Purge files consumed by 'load_spec'
56 shutil.rmtree(output_folder / 'constants/keycodes/')
57
58
59def _filtered_copy(src, dst):
60 src = Path(src)
61 dst = Path(dst)
62
63 if dst.suffix == '.hjson':
64 data = json_load(src)
65
66 dst = dst.with_suffix('.json')
67 dst.write_text(json.dumps(data, separators=(',', ':')), encoding='utf-8')
68 return dst
69
70 if dst.suffix == '.jsonschema':
71 data = json_load(src)
72
73 dst.write_text(json.dumps(data), encoding='utf-8')
74 return dst
75
76 return shutil.copy2(src, dst)
77
78
79def _filtered_keyboard_list():
80 """Perform basic filtering of list_keyboards
81 """
82 keyboard_list = list_keyboards()
83 if cli.args.filter:
84 kb_list = []
85 for keyboard_name in keyboard_list:
86 if any(i in keyboard_name for i in cli.args.filter):
87 kb_list.append(keyboard_name)
88 keyboard_list = kb_list
89 return keyboard_list
90
91
92@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't write the data to disk.")
93@cli.argument('-f', '--filter', arg_only=True, action='append', default=[], help="Filter the list of keyboards based on partial name matches the supplied value. May be passed multiple times.")
94@cli.subcommand('Generate QMK API data', hidden=False if cli.config.user.developer else True)
95def generate_api(cli):
96 """Generates the QMK API data.
97 """
98 v1_dir = BUILD_API_PATH / 'v1'
99 keyboard_all_file = v1_dir / 'keyboards.json' # A massive JSON containing everything
100 keyboard_list_file = v1_dir / 'keyboard_list.json' # A simple list of keyboard targets
101 keyboard_aliases_file = v1_dir / 'keyboard_aliases.json' # A list of historical keyboard names and their new name
102 keyboard_metadata_file = v1_dir / 'keyboard_metadata.json' # All the data configurator/via needs for initialization
103 constants_metadata_file = v1_dir / 'constants_metadata.json' # Metadata for available constants
104 usb_file = v1_dir / 'usb.json' # A mapping of USB VID/PID -> keyboard target
105
106 if BUILD_API_PATH.exists():
107 shutil.rmtree(BUILD_API_PATH)
108
109 shutil.copytree(TEMPLATE_PATH, BUILD_API_PATH)
110 shutil.copytree(DATA_PATH, v1_dir, copy_function=_filtered_copy)
111
112 # Filter down when required
113 keyboard_list = _filtered_keyboard_list()
114
115 kb_all = {}
116 usb_list = {}
117
118 # Generate and write keyboard specific JSON files
119 for keyboard_name in keyboard_list:
120 kb_json = info_json(keyboard_name)
121 kb_all[keyboard_name] = kb_json
122
123 keyboard_dir = v1_dir / 'keyboards' / keyboard_name
124 keyboard_info = keyboard_dir / 'info.json'
125 keyboard_readme = keyboard_dir / 'readme.md'
126 keyboard_readme_src = find_readme(keyboard_name)
127
128 # Populate the list of JSON keymaps
129 for keymap in list_keymaps(keyboard_name, c=False, fullpath=True):
130 keymap_rel = qmk.path.under_qmk_firmware(keymap)
131 if keymap_rel is None:
132 cli.log.debug('Skipping keymap %s (not in qmk_firmware)', keymap)
133 continue
134
135 if (keymap_rel / 'keymap.c').exists():
136 cli.log.debug('Skipping keymap %s (not pure dd keymap)', keymap)
137 continue
138
139 kb_json['keymaps'][keymap.name] = {
140 # TODO: deprecate 'url' as consumer needs to know its potentially hjson
141 'url': f'https://raw.githubusercontent.com/qmk/qmk_firmware/master/{keymap_rel}/keymap.json',
142
143 # Instead consumer should grab from API and not repo directly
144 'path': (keymap_rel / 'keymap.json').as_posix(),
145 }
146
147 keyboard_dir.mkdir(parents=True, exist_ok=True)
148 keyboard_json = json.dumps({'last_updated': current_datetime(), 'keyboards': {keyboard_name: kb_json}}, separators=(',', ':'))
149 if not cli.args.dry_run:
150 keyboard_info.write_text(keyboard_json, encoding='utf-8')
151 cli.log.debug('Wrote file %s', keyboard_info)
152
153 if keyboard_readme_src:
154 shutil.copyfile(keyboard_readme_src, keyboard_readme)
155 cli.log.debug('Copied %s -> %s', keyboard_readme_src, keyboard_readme)
156
157 # resolve keymaps as json
158 for keymap in kb_json['keymaps']:
159 keymap_hjson = kb_json['keymaps'][keymap]['path']
160 keymap_json = v1_dir / keymap_hjson
161 keymap_json.parent.mkdir(parents=True, exist_ok=True)
162 keymap_json.write_text(json.dumps(json_load(Path(keymap_hjson)), separators=(',', ':')), encoding='utf-8')
163 cli.log.debug('Wrote keymap %s', keymap_json)
164
165 if 'usb' in kb_json:
166 usb = kb_json['usb']
167
168 if 'vid' in usb and usb['vid'] not in usb_list:
169 usb_list[usb['vid']] = {}
170
171 if 'pid' in usb and usb['pid'] not in usb_list[usb['vid']]:
172 usb_list[usb['vid']][usb['pid']] = {}
173
174 if 'vid' in usb and 'pid' in usb:
175 usb_list[usb['vid']][usb['pid']][keyboard_name] = usb
176
177 # Generate data for the global files
178 keyboard_list = sorted(kb_all)
179 keyboard_aliases = keyboard_alias_definitions()
180 keyboard_metadata = {
181 'last_updated': current_datetime(),
182 'keyboards': keyboard_list,
183 'keyboard_aliases': keyboard_aliases,
184 'usb': usb_list,
185 }
186
187 # Feature specific handling
188 _resolve_keycode_specs(v1_dir)
189
190 # Write the global JSON files
191 keyboard_all_json = json.dumps({'last_updated': current_datetime(), 'keyboards': kb_all}, separators=(',', ':'))
192 usb_json = json.dumps({'last_updated': current_datetime(), 'usb': usb_list}, separators=(',', ':'))
193 keyboard_list_json = json.dumps({'last_updated': current_datetime(), 'keyboards': keyboard_list}, separators=(',', ':'))
194 keyboard_aliases_json = json.dumps({'last_updated': current_datetime(), 'keyboard_aliases': keyboard_aliases}, separators=(',', ':'))
195 keyboard_metadata_json = json.dumps(keyboard_metadata, separators=(',', ':'))
196 constants_metadata_json = json.dumps({'last_updated': current_datetime(), 'constants': _list_constants(v1_dir)}, separators=(',', ':'))
197
198 if not cli.args.dry_run:
199 keyboard_all_file.write_text(keyboard_all_json, encoding='utf-8')
200 usb_file.write_text(usb_json, encoding='utf-8')
201 keyboard_list_file.write_text(keyboard_list_json, encoding='utf-8')
202 keyboard_aliases_file.write_text(keyboard_aliases_json, encoding='utf-8')
203 keyboard_metadata_file.write_text(keyboard_metadata_json, encoding='utf-8')
204 constants_metadata_file.write_text(constants_metadata_json, encoding='utf-8')
diff --git a/lib/python/qmk/cli/generate/autocorrect_data.py b/lib/python/qmk/cli/generate/autocorrect_data.py
new file mode 100644
index 0000000000..4f322adce2
--- /dev/null
+++ b/lib/python/qmk/cli/generate/autocorrect_data.py
@@ -0,0 +1,291 @@
1# Copyright 2021 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Python program to make autocorrect_data.h.
15This program reads from a prepared dictionary file and generates a C source file
16"autocorrect_data.h" with a serialized trie embedded as an array. Run this
17program and pass it as the first argument like:
18$ qmk generate-autocorrect-data autocorrect_dict.txt
19Each line of the dict file defines one typo and its correction with the syntax
20"typo -> correction". Blank lines or lines starting with '#' are ignored.
21Example:
22 :thier -> their
23 fitler -> filter
24 lenght -> length
25 ouput -> output
26 widht -> width
27For full documentation, see QMK Docs
28"""
29
30import textwrap
31from typing import Any, Dict, Iterator, List, Tuple
32
33from milc import cli
34
35from qmk.commands import dump_lines
36from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
37from qmk.keyboard import keyboard_completer, keyboard_folder
38from qmk.keymap import keymap_completer, locate_keymap
39from qmk.path import normpath
40from qmk.util import maybe_exit
41
42KC_A = 4
43KC_SPC = 0x2c
44KC_QUOT = 0x34
45
46TYPO_CHARS = dict([
47 ("'", KC_QUOT),
48 (':', KC_SPC), # "Word break" character.
49] + [(chr(c), c + KC_A - ord('a')) for c in range(ord('a'),
50 ord('z') + 1)]) # Characters a-z.
51
52
53def parse_file(file_name: str) -> List[Tuple[str, str]]:
54 """Parses autocorrections dictionary file.
55 Each line of the file defines one typo and its correction with the syntax
56 "typo -> correction". Blank lines or lines starting with '#' are ignored. The
57 function validates that typos only have characters a-z and that typos are not
58 substrings of other typos, otherwise the longer typo would never trigger.
59 Args:
60 file_name: String, path of the autocorrections dictionary.
61 Returns:
62 List of (typo, correction) tuples.
63 """
64
65 try:
66 import english_words
67 correct_words = english_words.get_english_words_set(['web2'], lower=True, alpha=True)
68 except AttributeError:
69 from english_words import english_words_lower_alpha_set as correct_words
70 if not cli.args.quiet:
71 cli.echo('The english_words package is outdated, update by running:')
72 cli.echo(' {fg_cyan}python3 -m pip install english_words --upgrade')
73 except ImportError:
74 if not cli.args.quiet:
75 cli.echo('Autocorrection will falsely trigger when a typo is a substring of a correctly spelled word.')
76 cli.echo('To check for this, install the english_words package and rerun this script:')
77 cli.echo(' {fg_cyan}python3 -m pip install english_words')
78 # Use a minimal word list as a fallback.
79 correct_words = ('information', 'available', 'international', 'language', 'loosest', 'reference', 'wealthier', 'entertainment', 'association', 'provides', 'technology', 'statehood')
80
81 autocorrections = []
82 typos = set()
83 for line_number, typo, correction in parse_file_lines(file_name):
84 if typo in typos:
85 cli.log.warning('{fg_red}Error:%d:{fg_reset} Ignoring duplicate typo: "{fg_cyan}%s{fg_reset}"', line_number, typo)
86 continue
87
88 # Check that `typo` is valid.
89 if not (all([c in TYPO_CHARS for c in typo])):
90 cli.log.error('{fg_red}Error:%d:{fg_reset} Typo "{fg_cyan}%s{fg_reset}" has characters other than a-z, \' and :.', line_number, typo)
91 maybe_exit(1)
92 for other_typo in typos:
93 if typo in other_typo or other_typo in typo:
94 cli.log.error('{fg_red}Error:%d:{fg_reset} Typos may not be substrings of one another, otherwise the longer typo would never trigger: "{fg_cyan}%s{fg_reset}" vs. "{fg_cyan}%s{fg_reset}".', line_number, typo, other_typo)
95 maybe_exit(1)
96 if len(typo) < 5:
97 cli.log.warning('{fg_yellow}Warning:%d:{fg_reset} It is suggested that typos are at least 5 characters long to avoid false triggers: "{fg_cyan}%s{fg_reset}"', line_number, typo)
98 if len(typo) > 127:
99 cli.log.error('{fg_red}Error:%d:{fg_reset} Typo exceeds 127 chars: "{fg_cyan}%s{fg_reset}"', line_number, typo)
100 maybe_exit(1)
101
102 check_typo_against_dictionary(typo, line_number, correct_words)
103
104 autocorrections.append((typo, correction))
105 typos.add(typo)
106
107 return autocorrections
108
109
110def make_trie(autocorrections: List[Tuple[str, str]]) -> Dict[str, Any]:
111 """Makes a trie from the the typos, writing in reverse.
112 Args:
113 autocorrections: List of (typo, correction) tuples.
114 Returns:
115 Dict of dict, representing the trie.
116 """
117 trie = {}
118 for typo, correction in autocorrections:
119 node = trie
120 for letter in typo[::-1]:
121 node = node.setdefault(letter, {})
122 node['LEAF'] = (typo, correction)
123
124 return trie
125
126
127def parse_file_lines(file_name: str) -> Iterator[Tuple[int, str, str]]:
128 """Parses lines read from `file_name` into typo-correction pairs."""
129
130 line_number = 0
131 for line in open(file_name, 'rt'):
132 line_number += 1
133 line = line.strip()
134 if line and line[0] != '#':
135 # Parse syntax "typo -> correction", using strip to ignore indenting.
136 tokens = [token.strip() for token in line.split('->', 1)]
137 if len(tokens) != 2 or not tokens[0]:
138 print(f'Error:{line_number}: Invalid syntax: "{line}"')
139 maybe_exit(1)
140
141 typo, correction = tokens
142 typo = typo.lower() # Force typos to lowercase.
143 typo = typo.replace(' ', ':')
144
145 yield line_number, typo, correction
146
147
148def check_typo_against_dictionary(typo: str, line_number: int, correct_words) -> None:
149 """Checks `typo` against English dictionary words."""
150
151 if typo.startswith(':') and typo.endswith(':'):
152 if typo[1:-1] in correct_words:
153 cli.log.warning('{fg_yellow}Warning:%d:{fg_reset} Typo "{fg_cyan}%s{fg_reset}" is a correctly spelled dictionary word.', line_number, typo)
154 elif typo.startswith(':') and not typo.endswith(':'):
155 for word in correct_words:
156 if word.startswith(typo[1:]):
157 cli.log.warning('{fg_yellow}Warning:%d: {fg_reset}Typo "{fg_cyan}%s{fg_reset}" would falsely trigger on correctly spelled word "{fg_cyan}%s{fg_reset}".', line_number, typo, word)
158 elif not typo.startswith(':') and typo.endswith(':'):
159 for word in correct_words:
160 if word.endswith(typo[:-1]):
161 cli.log.warning('{fg_yellow}Warning:%d:{fg_reset} Typo "{fg_cyan}%s{fg_reset}" would falsely trigger on correctly spelled word "{fg_cyan}%s{fg_reset}".', line_number, typo, word)
162 elif not typo.startswith(':') and not typo.endswith(':'):
163 for word in correct_words:
164 if typo in word:
165 cli.log.warning('{fg_yellow}Warning:%d:{fg_reset} Typo "{fg_cyan}%s{fg_reset}" would falsely trigger on correctly spelled word "{fg_cyan}%s{fg_reset}".', line_number, typo, word)
166
167
168def serialize_trie(autocorrections: List[Tuple[str, str]], trie: Dict[str, Any]) -> List[int]:
169 """Serializes trie and correction data in a form readable by the C code.
170 Args:
171 autocorrections: List of (typo, correction) tuples.
172 trie: Dict of dicts.
173 Returns:
174 List of ints in the range 0-255.
175 """
176 table = []
177
178 # Traverse trie in depth first order.
179 def traverse(trie_node):
180 if 'LEAF' in trie_node: # Handle a leaf trie node.
181 typo, correction = trie_node['LEAF']
182 word_boundary_ending = typo[-1] == ':'
183 typo = typo.strip(':')
184 i = 0 # Make the autocorrection data for this entry and serialize it.
185 while i < min(len(typo), len(correction)) and typo[i] == correction[i]:
186 i += 1
187 backspaces = len(typo) - i - 1 + word_boundary_ending
188 assert 0 <= backspaces <= 63
189 correction = correction[i:]
190 bs_count = [backspaces + 128]
191 data = bs_count + list(bytes(correction, 'ascii')) + [0]
192
193 entry = {'data': data, 'links': [], 'byte_offset': 0}
194 table.append(entry)
195 elif len(trie_node) == 1: # Handle trie node with a single child.
196 c, trie_node = next(iter(trie_node.items()))
197 entry = {'chars': c, 'byte_offset': 0}
198
199 # It's common for a trie to have long chains of single-child nodes. We
200 # find the whole chain so that we can serialize it more efficiently.
201 while len(trie_node) == 1 and 'LEAF' not in trie_node:
202 c, trie_node = next(iter(trie_node.items()))
203 entry['chars'] += c
204
205 table.append(entry)
206 entry['links'] = [traverse(trie_node)]
207 else: # Handle trie node with multiple children.
208 entry = {'chars': ''.join(sorted(trie_node.keys())), 'byte_offset': 0}
209 table.append(entry)
210 entry['links'] = [traverse(trie_node[c]) for c in entry['chars']]
211 return entry
212
213 traverse(trie)
214
215 def serialize(e: Dict[str, Any]) -> List[int]:
216 if not e['links']: # Handle a leaf table entry.
217 return e['data']
218 elif len(e['links']) == 1: # Handle a chain table entry.
219 return [TYPO_CHARS[c] for c in e['chars']] + [0] # + encode_link(e['links'][0]))
220 else: # Handle a branch table entry.
221 data = []
222 for c, link in zip(e['chars'], e['links']):
223 data += [TYPO_CHARS[c] | (0 if data else 64)] + encode_link(link)
224 return data + [0]
225
226 byte_offset = 0
227 for e in table: # To encode links, first compute byte offset of each entry.
228 e['byte_offset'] = byte_offset
229 byte_offset += len(serialize(e))
230 assert 0 <= byte_offset <= 0xffff
231
232 return [b for e in table for b in serialize(e)] # Serialize final table.
233
234
235def encode_link(link: Dict[str, Any]) -> List[int]:
236 """Encodes a node link as two bytes."""
237 byte_offset = link['byte_offset']
238 if not (0 <= byte_offset <= 0xffff):
239 cli.log.error('{fg_red}Error:{fg_reset} The autocorrection table is too large, a node link exceeds 64KB limit. Try reducing the autocorrection dict to fewer entries.')
240 maybe_exit(1)
241 return [byte_offset & 255, byte_offset >> 8]
242
243
244def typo_len(e: Tuple[str, str]) -> int:
245 return len(e[0])
246
247
248def to_hex(b: int) -> str:
249 return f'0x{b:02X}'
250
251
252@cli.argument('filename', type=normpath, help='The autocorrection database file')
253@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a output file is supplied.')
254@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a output file is supplied.')
255@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
256@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
257@cli.subcommand('Generate the autocorrection data file from a dictionary file.')
258def generate_autocorrect_data(cli):
259 autocorrections = parse_file(cli.args.filename)
260 trie = make_trie(autocorrections)
261 data = serialize_trie(autocorrections, trie)
262
263 current_keyboard = cli.args.keyboard or cli.config.user.keyboard or cli.config.generate_autocorrect_data.keyboard
264 current_keymap = cli.args.keymap or cli.config.user.keymap or cli.config.generate_autocorrect_data.keymap
265
266 if not cli.args.output and current_keyboard and current_keymap:
267 cli.args.output = locate_keymap(current_keyboard, current_keymap).parent / 'autocorrect_data.h'
268
269 assert all(0 <= b <= 255 for b in data)
270
271 min_typo = min(autocorrections, key=typo_len)[0]
272 max_typo = max(autocorrections, key=typo_len)[0]
273
274 # Build the autocorrect_data.h file.
275 autocorrect_data_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once', '']
276
277 autocorrect_data_h_lines.append(f'// Autocorrection dictionary ({len(autocorrections)} entries):')
278 for typo, correction in autocorrections:
279 autocorrect_data_h_lines.append(f'// {typo:<{len(max_typo)}} -> {correction}')
280
281 autocorrect_data_h_lines.append('')
282 autocorrect_data_h_lines.append(f'#define AUTOCORRECT_MIN_LENGTH {len(min_typo)} // "{min_typo}"')
283 autocorrect_data_h_lines.append(f'#define AUTOCORRECT_MAX_LENGTH {len(max_typo)} // "{max_typo}"')
284 autocorrect_data_h_lines.append(f'#define DICTIONARY_SIZE {len(data)}')
285 autocorrect_data_h_lines.append('')
286 autocorrect_data_h_lines.append('static const uint8_t autocorrect_data[DICTIONARY_SIZE] PROGMEM = {')
287 autocorrect_data_h_lines.append(textwrap.fill(' %s' % (', '.join(map(to_hex, data))), width=100, subsequent_indent=' '))
288 autocorrect_data_h_lines.append('};')
289
290 # Show the results
291 dump_lines(cli.args.output, autocorrect_data_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/community_modules.py b/lib/python/qmk/cli/generate/community_modules.py
new file mode 100644
index 0000000000..a5ab61f9bd
--- /dev/null
+++ b/lib/python/qmk/cli/generate/community_modules.py
@@ -0,0 +1,341 @@
1import contextlib
2from argcomplete.completers import FilesCompleter
3from pathlib import Path
4
5from milc import cli
6
7import qmk.path
8from qmk.info import get_modules
9from qmk.keyboard import keyboard_completer, keyboard_folder
10from qmk.commands import dump_lines
11from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE
12from qmk.community_modules import module_api_list, load_module_jsons, find_module_path
13
14
15@contextlib.contextmanager
16def _render_api_guard(lines, api):
17 if api.guard:
18 lines.append(f'#if {api.guard}')
19 yield
20 if api.guard:
21 lines.append(f'#endif // {api.guard}')
22
23
24def _render_api_header(api):
25 lines = []
26 if api.header:
27 lines.append('')
28 with _render_api_guard(lines, api):
29 lines.append(f'#include <{api.header}>')
30 return lines
31
32
33def _render_keycodes(module_jsons):
34 lines = []
35 lines.append('')
36 lines.append('enum {')
37 first = True
38 for module_json in module_jsons:
39 module_name = Path(module_json['module']).name
40 keycodes = module_json.get('keycodes', [])
41 if len(keycodes) > 0:
42 lines.append(f' // From module: {module_name}')
43 for keycode in keycodes:
44 key = keycode.get('key', None)
45 if first:
46 lines.append(f' {key} = QK_COMMUNITY_MODULE,')
47 first = False
48 else:
49 lines.append(f' {key},')
50 for alias in keycode.get('aliases', []):
51 lines.append(f' {alias} = {key},')
52 lines.append('')
53 lines.append(' LAST_COMMUNITY_MODULE_KEY')
54 lines.append('};')
55 lines.append('STATIC_ASSERT((int)LAST_COMMUNITY_MODULE_KEY <= (int)(QK_COMMUNITY_MODULE_MAX+1), "Too many community module keycodes");')
56 return lines
57
58
59def _render_api_declarations(api, module, user_kb=True):
60 lines = []
61 lines.append('')
62 with _render_api_guard(lines, api):
63 if user_kb:
64 lines.append(f'{api.ret_type} {api.name}_{module}_user({api.args});')
65 lines.append(f'{api.ret_type} {api.name}_{module}_kb({api.args});')
66 lines.append(f'{api.ret_type} {api.name}_{module}({api.args});')
67 return lines
68
69
70def _render_api_implementations(api, module):
71 module_name = Path(module).name
72 lines = []
73 lines.append('')
74 with _render_api_guard(lines, api):
75 # _user
76 lines.append(f'__attribute__((weak)) {api.ret_type} {api.name}_{module_name}_user({api.args}) {{')
77 if api.ret_type == 'bool':
78 lines.append(' return true;')
79 elif api.ret_type in ['layer_state_t', 'report_mouse_t']:
80 lines.append(f' return {api.call_params};')
81 else:
82 pass
83 lines.append('}')
84 lines.append('')
85
86 # _kb
87 lines.append(f'__attribute__((weak)) {api.ret_type} {api.name}_{module_name}_kb({api.args}) {{')
88 if api.ret_type == 'bool':
89 lines.append(f' if(!{api.name}_{module_name}_user({api.call_params})) {{ return false; }}')
90 lines.append(' return true;')
91 elif api.ret_type in ['layer_state_t', 'report_mouse_t']:
92 lines.append(f' return {api.name}_{module_name}_user({api.call_params});')
93 else:
94 lines.append(f' {api.name}_{module_name}_user({api.call_params});')
95 lines.append('}')
96 lines.append('')
97
98 # module (non-suffixed)
99 lines.append(f'__attribute__((weak)) {api.ret_type} {api.name}_{module_name}({api.args}) {{')
100 if api.ret_type == 'bool':
101 lines.append(f' if(!{api.name}_{module_name}_kb({api.call_params})) {{ return false; }}')
102 lines.append(' return true;')
103 elif api.ret_type in ['layer_state_t', 'report_mouse_t']:
104 lines.append(f' return {api.name}_{module_name}_kb({api.call_params});')
105 else:
106 lines.append(f' {api.name}_{module_name}_kb({api.call_params});')
107 lines.append('}')
108 return lines
109
110
111def _render_core_implementation(api, modules):
112 lines = []
113 lines.append('')
114 with _render_api_guard(lines, api):
115 lines.append(f'{api.ret_type} {api.name}_modules({api.args}) {{')
116 if api.ret_type == 'bool':
117 lines.append(' return true')
118 for module in modules:
119 module_name = Path(module).name
120 if api.ret_type == 'bool':
121 lines.append(f' && {api.name}_{module_name}({api.call_params})')
122 elif api.ret_type in ['layer_state_t', 'report_mouse_t']:
123 lines.append(f' {api.call_params} = {api.name}_{module_name}({api.call_params});')
124 else:
125 lines.append(f' {api.name}_{module_name}({api.call_params});')
126 if api.ret_type == 'bool':
127 lines.append(' ;')
128 elif api.ret_type in ['layer_state_t', 'report_mouse_t']:
129 lines.append(f' return {api.call_params};')
130 lines.append('}')
131 return lines
132
133
134def _generate_features_rules(features_dict):
135 lines = []
136 for feature, enabled in features_dict.items():
137 feature = feature.upper()
138 enabled = 'yes' if enabled else 'no'
139 lines.append(f'{feature}_ENABLE={enabled}')
140 return lines
141
142
143def _generate_modules_rules(keyboard, filename):
144 lines = []
145 modules = get_modules(keyboard, filename)
146 if len(modules) > 0:
147 lines.append('')
148 lines.append('OPT_DEFS += -DCOMMUNITY_MODULES_ENABLE=TRUE')
149 for module in modules:
150 module_path = qmk.path.unix_style_path(find_module_path(module))
151 if not module_path:
152 raise FileNotFoundError(f"Module '{module}' not found.")
153 lines.append('')
154 lines.append(f'COMMUNITY_MODULES += {module_path.name}') # use module_path here instead of module as it may be a subdirectory
155 lines.append(f'OPT_DEFS += -DCOMMUNITY_MODULE_{module_path.name.upper()}_ENABLE=TRUE')
156 lines.append(f'COMMUNITY_MODULE_PATHS += {module_path}')
157 lines.append(f'VPATH += {module_path}')
158 lines.append(f'SRC += $(wildcard {module_path}/{module_path.name}.c)')
159 lines.append(f'MODULE_NAME_{module_path.name.upper()} := {module_path.name}')
160 lines.append(f'MODULE_PATH_{module_path.name.upper()} := {module_path}')
161 lines.append(f'-include {module_path}/rules.mk')
162
163 module_jsons = load_module_jsons(modules)
164 for module_json in module_jsons:
165 if 'features' in module_json:
166 lines.append('')
167 lines.append(f'# Module: {module_json["module_name"]}')
168 lines.extend(_generate_features_rules(module_json['features']))
169 return lines
170
171
172@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
173@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
174@cli.argument('-e', '--escape', arg_only=True, action='store_true', help="Escape spaces in quiet mode")
175@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate rules.mk for.')
176@cli.argument('filename', nargs='?', arg_only=True, type=qmk.path.FileType('r'), completer=FilesCompleter('.json'), help='A configurator export JSON to be compiled and flashed or a pre-compiled binary firmware file (bin/hex) to be flashed.')
177@cli.subcommand('Creates a community_modules_rules_mk from a keymap.json file.')
178def generate_community_modules_rules_mk(cli):
179
180 rules_mk_lines = [GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE]
181
182 rules_mk_lines.extend(_generate_modules_rules(cli.args.keyboard, cli.args.filename))
183
184 # Show the results
185 dump_lines(cli.args.output, rules_mk_lines)
186
187 if cli.args.output:
188 if cli.args.quiet:
189 if cli.args.escape:
190 print(cli.args.output.as_posix().replace(' ', '\\ '))
191 else:
192 print(cli.args.output)
193 else:
194 cli.log.info('Wrote rules.mk to %s.', cli.args.output)
195
196
197@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
198@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
199@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.h for.')
200@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
201@cli.subcommand('Creates a community_modules.h from a keymap.json file.')
202def generate_community_modules_h(cli):
203 """Creates a community_modules.h from a keymap.json file
204 """
205 if cli.args.output and cli.args.output.name == '-':
206 cli.args.output = None
207
208 api_list, api_version, ver_major, ver_minor, ver_patch = module_api_list()
209
210 lines = [
211 GPL2_HEADER_C_LIKE,
212 GENERATED_HEADER_C_LIKE,
213 '#pragma once',
214 '#include <stdint.h>',
215 '#include <stdbool.h>',
216 '#include <keycodes.h>',
217 '',
218 '#include "compiler_support.h"',
219 '',
220 '#define COMMUNITY_MODULES_API_VERSION_BUILDER(ver_major,ver_minor,ver_patch) (((((uint32_t)(ver_major))&0xFF) << 24) | ((((uint32_t)(ver_minor))&0xFF) << 16) | (((uint32_t)(ver_patch))&0xFF))',
221 f'#define COMMUNITY_MODULES_API_VERSION COMMUNITY_MODULES_API_VERSION_BUILDER({ver_major},{ver_minor},{ver_patch})',
222 f'#define ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(ver_major,ver_minor,ver_patch) STATIC_ASSERT(COMMUNITY_MODULES_API_VERSION_BUILDER(ver_major,ver_minor,ver_patch) <= COMMUNITY_MODULES_API_VERSION, "Community module requires a newer version of QMK modules API -- needs: " #ver_major "." #ver_minor "." #ver_patch ", current: {api_version}.")',
223 '',
224 'typedef struct keyrecord_t keyrecord_t; // forward declaration so we don\'t need to include quantum.h',
225 '',
226 ]
227
228 modules = get_modules(cli.args.keyboard, cli.args.filename)
229 module_jsons = load_module_jsons(modules)
230 if len(modules) > 0:
231 lines.extend(_render_keycodes(module_jsons))
232
233 for api in api_list:
234 lines.extend(_render_api_header(api))
235
236 for module in modules:
237 lines.append('')
238 lines.append(f'// From module: {module}')
239 for api in api_list:
240 lines.extend(_render_api_declarations(api, Path(module).name))
241 lines.append('')
242
243 lines.append('// Core wrapper')
244 for api in api_list:
245 lines.extend(_render_api_declarations(api, 'modules', user_kb=False))
246
247 dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)
248
249
250@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
251@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
252@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.c for.')
253@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
254@cli.subcommand('Creates a community_modules.c from a keymap.json file.')
255def generate_community_modules_c(cli):
256 """Creates a community_modules.c from a keymap.json file
257 """
258 if cli.args.output and cli.args.output.name == '-':
259 cli.args.output = None
260
261 api_list, _, _, _, _ = module_api_list()
262
263 lines = [
264 GPL2_HEADER_C_LIKE,
265 GENERATED_HEADER_C_LIKE,
266 '',
267 '#include "community_modules.h"',
268 ]
269
270 modules = get_modules(cli.args.keyboard, cli.args.filename)
271 if len(modules) > 0:
272
273 for module in modules:
274 for api in api_list:
275 lines.extend(_render_api_implementations(api, Path(module).name))
276
277 for api in api_list:
278 lines.extend(_render_core_implementation(api, modules))
279
280 dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)
281
282
283def _generate_include_per_module(cli, include_file_name):
284 """Generates C code to include "<module_path>/include_file_name" for each module."""
285 if cli.args.output and cli.args.output.name == '-':
286 cli.args.output = None
287
288 lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE]
289
290 for module in get_modules(cli.args.keyboard, cli.args.filename):
291 full_path = f'{find_module_path(module)}/{include_file_name}'
292 lines.append('')
293 lines.append(f'#if __has_include("{full_path}")')
294 lines.append(f'#include "{full_path}"')
295 lines.append(f'#endif // __has_include("{full_path}")')
296
297 dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)
298
299
300@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
301@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
302@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules_introspection.h for.')
303@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
304@cli.subcommand('Creates a community_modules_introspection.h from a keymap.json file.')
305def generate_community_modules_introspection_h(cli):
306 """Creates a community_modules_introspection.h from a keymap.json file
307 """
308 _generate_include_per_module(cli, 'introspection.h')
309
310
311@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
312@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
313@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.c for.')
314@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
315@cli.subcommand('Creates a community_modules_introspection.c from a keymap.json file.')
316def generate_community_modules_introspection_c(cli):
317 """Creates a community_modules_introspection.c from a keymap.json file
318 """
319 _generate_include_per_module(cli, 'introspection.c')
320
321
322@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
323@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
324@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate led_matrix_community_modules.inc for.')
325@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
326@cli.subcommand('Creates an led_matrix_community_modules.inc from a keymap.json file.')
327def generate_led_matrix_community_modules_inc(cli):
328 """Creates an led_matrix_community_modules.inc from a keymap.json file
329 """
330 _generate_include_per_module(cli, 'led_matrix_module.inc')
331
332
333@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
334@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
335@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate rgb_matrix_community_modules.inc for.')
336@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
337@cli.subcommand('Creates an rgb_matrix_community_modules.inc from a keymap.json file.')
338def generate_rgb_matrix_community_modules_inc(cli):
339 """Creates an rgb_matrix_community_modules.inc from a keymap.json file
340 """
341 _generate_include_per_module(cli, 'rgb_matrix_module.inc')
diff --git a/lib/python/qmk/cli/generate/compilation_database.py b/lib/python/qmk/cli/generate/compilation_database.py
new file mode 100644
index 0000000000..339b53c2c2
--- /dev/null
+++ b/lib/python/qmk/cli/generate/compilation_database.py
@@ -0,0 +1,9 @@
1from milc import cli
2
3
4@cli.argument('-kb', '--keyboard', help='[unused] The keyboard\'s name')
5@cli.argument('-km', '--keymap', help='[unused] The keymap\'s name')
6@cli.subcommand('[deprecated] Create a compilation database.')
7def generate_compilation_database(cli):
8 cli.log.error('This command is deprecated and has effectively been removed. Please use the `--compiledb` flag with `qmk compile` instead.')
9 return False
diff --git a/lib/python/qmk/cli/generate/config_h.py b/lib/python/qmk/cli/generate/config_h.py
new file mode 100755
index 0000000000..1ade452f95
--- /dev/null
+++ b/lib/python/qmk/cli/generate/config_h.py
@@ -0,0 +1,212 @@
1"""Used by the make system to generate info_config.h from info.json.
2"""
3from pathlib import Path
4from dotty_dict import dotty
5
6from argcomplete.completers import FilesCompleter
7from milc import cli
8
9from qmk.info import info_json
10from qmk.json_schema import json_load
11from qmk.keyboard import keyboard_completer, keyboard_folder
12from qmk.commands import dump_lines, parse_configurator_json
13from qmk.path import normpath, FileType
14from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
15
16
17def generate_define(define, value=None):
18 is_keymap = cli.args.filename
19 value = f' {value}' if value is not None else ''
20 if is_keymap:
21 return f"""
22#undef {define}
23#define {define}{value}"""
24 return f"""
25#ifndef {define}
26# define {define}{value}
27#endif // {define}"""
28
29
30def direct_pins(direct_pins, postfix):
31 """Return the config.h lines that set the direct pins.
32 """
33 rows = []
34
35 for row in direct_pins:
36 cols = ','.join(map(str, [col or 'NO_PIN' for col in row]))
37 rows.append('{' + cols + '}')
38
39 return generate_define(f'DIRECT_PINS{postfix}', f'{{ {", ".join(rows)} }}')
40
41
42def pin_array(define, pins, postfix):
43 """Return the config.h lines that set a pin array.
44 """
45 pin_array = ', '.join(map(str, [pin or 'NO_PIN' for pin in pins]))
46
47 return generate_define(f'{define}_PINS{postfix}', f'{{ {pin_array} }}')
48
49
50def matrix_pins(matrix_pins, postfix=''):
51 """Add the matrix config to the config.h.
52 """
53 pins = []
54
55 if 'direct' in matrix_pins:
56 pins.append(direct_pins(matrix_pins['direct'], postfix))
57
58 if 'cols' in matrix_pins:
59 pins.append(pin_array('MATRIX_COL', matrix_pins['cols'], postfix))
60
61 if 'rows' in matrix_pins:
62 pins.append(pin_array('MATRIX_ROW', matrix_pins['rows'], postfix))
63
64 return '\n'.join(pins)
65
66
67def generate_matrix_size(kb_info_json, config_h_lines):
68 """Add the matrix size to the config.h.
69 """
70 if 'matrix_size' in kb_info_json:
71 config_h_lines.append(generate_define('MATRIX_COLS', kb_info_json['matrix_size']['cols']))
72 config_h_lines.append(generate_define('MATRIX_ROWS', kb_info_json['matrix_size']['rows']))
73
74
75def generate_config_items(kb_info_json, config_h_lines):
76 """Iterate through the info_config map to generate basic config values.
77 """
78 info_config_map = json_load(Path('data/mappings/info_config.hjson'))
79
80 for config_key, info_dict in info_config_map.items():
81 info_key = info_dict['info_key']
82 key_type = info_dict.get('value_type', 'raw')
83 to_c = info_dict.get('to_c', True)
84
85 if not to_c:
86 continue
87
88 try:
89 config_value = kb_info_json[info_key]
90 except KeyError:
91 continue
92
93 if key_type.startswith('array.array'):
94 config_h_lines.append(generate_define(config_key, f'{{ {", ".join(["{" + ",".join(list(map(str, x))) + "}" for x in config_value])} }}'))
95 elif key_type.startswith('array'):
96 config_h_lines.append(generate_define(config_key, f'{{ {", ".join(map(str, config_value))} }}'))
97 elif key_type == 'bool':
98 config_h_lines.append(generate_define(config_key, 'true' if config_value else 'false'))
99 elif key_type == 'flag':
100 if config_value:
101 config_h_lines.append(generate_define(config_key))
102 elif key_type == 'mapping':
103 for key, value in config_value.items():
104 config_h_lines.append(generate_define(key, value))
105 elif key_type == 'str':
106 escaped_str = config_value.replace('\\', '\\\\').replace('"', '\\"')
107 config_h_lines.append(generate_define(config_key, f'"{escaped_str}"'))
108 elif key_type == 'bcd_version':
109 (major, minor, revision) = config_value.split('.')
110 config_h_lines.append(generate_define(config_key, f'0x{major.zfill(2)}{minor}{revision}'))
111 else:
112 config_h_lines.append(generate_define(config_key, config_value))
113
114
115def generate_encoder_config(encoder_json, config_h_lines, postfix=''):
116 """Generate the config.h lines for encoders."""
117 a_pads = []
118 b_pads = []
119 resolutions = []
120 for encoder in encoder_json.get("rotary", []):
121 a_pads.append(encoder["pin_a"])
122 b_pads.append(encoder["pin_b"])
123 resolutions.append(encoder.get("resolution", None))
124
125 config_h_lines.append(generate_define(f'ENCODER_A_PINS{postfix}', f'{{ {", ".join(a_pads)} }}'))
126 config_h_lines.append(generate_define(f'ENCODER_B_PINS{postfix}', f'{{ {", ".join(b_pads)} }}'))
127
128 if len(resolutions) == 0 or all(r is None for r in resolutions):
129 cli.log.debug(f"Skipping ENCODER_RESOLUTION{postfix} configuration")
130 return
131
132 resolutions = [4 if r is None else r for r in resolutions]
133 if len(set(resolutions)) == 1:
134 config_h_lines.append(generate_define(f'ENCODER_RESOLUTION{postfix}', resolutions[0]))
135 else:
136 config_h_lines.append(generate_define(f'ENCODER_RESOLUTIONS{postfix}', f'{{ {", ".join(map(str,resolutions))} }}'))
137
138
139def generate_split_config(kb_info_json, config_h_lines):
140 """Generate the config.h lines for split boards."""
141 if 'handedness' in kb_info_json['split']:
142 # TODO: change SPLIT_HAND_MATRIX_GRID to require brackets
143 handedness = kb_info_json['split']['handedness']
144 if 'matrix_grid' in handedness:
145 config_h_lines.append(generate_define('SPLIT_HAND_MATRIX_GRID', ', '.join(handedness['matrix_grid'])))
146
147 if 'protocol' in kb_info_json['split'].get('transport', {}):
148 if kb_info_json['split']['transport']['protocol'] == 'i2c':
149 config_h_lines.append(generate_define('USE_I2C'))
150
151 if 'right' in kb_info_json['split'].get('matrix_pins', {}):
152 config_h_lines.append(matrix_pins(kb_info_json['split']['matrix_pins']['right'], '_RIGHT'))
153
154 if 'right' in kb_info_json['split'].get('encoder', {}):
155 generate_encoder_config(kb_info_json['split']['encoder']['right'], config_h_lines, '_RIGHT')
156
157
158def generate_led_animations_config(feature, led_feature_json, config_h_lines, enable_prefix, animation_prefix):
159 if 'animation' in led_feature_json.get('default', {}):
160 config_h_lines.append(generate_define(f'{feature.upper()}_DEFAULT_MODE', f'{animation_prefix}{led_feature_json["default"]["animation"].upper()}'))
161
162 for animation in led_feature_json.get('animations', {}):
163 if led_feature_json['animations'][animation]:
164 config_h_lines.append(generate_define(f'{enable_prefix}{animation.upper()}'))
165
166
167@cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), completer=FilesCompleter('.json'), help='A configurator export JSON to be compiled and flashed or a pre-compiled binary firmware file (bin/hex) to be flashed.')
168@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
169@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
170@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate config.h for.')
171@cli.subcommand('Used by the make system to generate info_config.h from info.json', hidden=True)
172def generate_config_h(cli):
173 """Generates the info_config.h file.
174 """
175 # Determine our keyboard/keymap
176 if cli.args.filename:
177 user_keymap = parse_configurator_json(cli.args.filename)
178 kb_info_json = dotty(user_keymap.get('config', {}))
179 elif cli.args.keyboard:
180 kb_info_json = dotty(info_json(cli.args.keyboard))
181 else:
182 cli.log.error('You must supply a configurator export or `--keyboard`.')
183 cli.subcommands['generate-config-h'].print_help()
184 return False
185
186 # Build the info_config.h file.
187 config_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once']
188
189 generate_config_items(kb_info_json, config_h_lines)
190
191 generate_matrix_size(kb_info_json, config_h_lines)
192
193 if 'matrix_pins' in kb_info_json:
194 config_h_lines.append(matrix_pins(kb_info_json['matrix_pins']))
195
196 if 'encoder' in kb_info_json:
197 generate_encoder_config(kb_info_json['encoder'], config_h_lines)
198
199 if 'split' in kb_info_json:
200 generate_split_config(kb_info_json, config_h_lines)
201
202 if 'led_matrix' in kb_info_json:
203 generate_led_animations_config('led_matrix', kb_info_json['led_matrix'], config_h_lines, 'ENABLE_LED_MATRIX_', 'LED_MATRIX_')
204
205 if 'rgb_matrix' in kb_info_json:
206 generate_led_animations_config('rgb_matrix', kb_info_json['rgb_matrix'], config_h_lines, 'ENABLE_RGB_MATRIX_', 'RGB_MATRIX_')
207
208 if 'rgblight' in kb_info_json:
209 generate_led_animations_config('rgblight', kb_info_json['rgblight'], config_h_lines, 'RGBLIGHT_EFFECT_', 'RGBLIGHT_MODE_')
210
211 # Show the results
212 dump_lines(cli.args.output, config_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/develop_pr_list.py b/lib/python/qmk/cli/generate/develop_pr_list.py
new file mode 100755
index 0000000000..549db5b185
--- /dev/null
+++ b/lib/python/qmk/cli/generate/develop_pr_list.py
@@ -0,0 +1,130 @@
1"""Export the initial list of PRs associated with a `develop` merge to `master`.
2"""
3import os
4import re
5from pathlib import Path
6from subprocess import DEVNULL
7
8from milc import cli
9
10cache_timeout = 7 * 86400
11fix_expr = re.compile(r'fix', flags=re.IGNORECASE)
12clean1_expr = re.compile(r'\[(develop|keyboard|keymap|core|cli|bug|docs|feature)\]', flags=re.IGNORECASE)
13clean2_expr = re.compile(r'^(develop|keyboard|keymap|core|cli|bug|docs|feature):', flags=re.IGNORECASE)
14
15ignored_titles = ["Format code according to conventions"]
16
17
18def _is_ignored(title):
19 for ignore in ignored_titles:
20 if ignore in title:
21 return True
22 return False
23
24
25def _get_pr_info(cache, gh, pr_num):
26 pull = cache.get(f'pull:{pr_num}')
27 if pull is None:
28 print(f'Retrieving info for PR #{pr_num}')
29 pull = gh.pulls.get(owner='qmk', repo='qmk_firmware', pull_number=pr_num)
30 cache.set(f'pull:{pr_num}', pull, cache_timeout)
31 return pull
32
33
34def _try_open_cache(cli):
35 # These dependencies are manually handled because people complain. Fun.
36 try:
37 from sqlite_cache.sqlite_cache import SqliteCache
38 except ImportError:
39 return None
40
41 cache_loc = Path(cli.config_file).parent
42 return SqliteCache(cache_loc)
43
44
45def _get_github():
46 try:
47 from ghapi.all import GhApi
48 except ImportError:
49 return None
50
51 return GhApi()
52
53
54@cli.argument('-f', '--from-ref', default='0.11.0', help='Git revision/tag/reference/branch to begin search')
55@cli.argument('-b', '--branch', default='upstream/develop', help='Git branch to iterate (default: "upstream/develop")')
56@cli.subcommand('Creates the develop PR list.', hidden=False if cli.config.user.developer else True)
57def generate_develop_pr_list(cli):
58 """Retrieves information from GitHub regarding the list of PRs associated
59 with a merge of `develop` branch into `master`.
60
61 Requires environment variable GITHUB_TOKEN to be set.
62 """
63
64 if 'GITHUB_TOKEN' not in os.environ or os.environ['GITHUB_TOKEN'] == '':
65 cli.log.error('Environment variable "GITHUB_TOKEN" is not set.')
66 return 1
67
68 cache = _try_open_cache(cli)
69 gh = _get_github()
70
71 git_args = ['git', 'rev-list', '--oneline', '--no-merges', '--reverse', f'{cli.args.from_ref}...{cli.args.branch}', '^upstream/master']
72 commit_list = cli.run(git_args, capture_output=True, stdin=DEVNULL)
73
74 if cache is None or gh is None:
75 cli.log.error('Missing one or more dependent python packages: "ghapi", "python-sqlite-cache"')
76 return 1
77
78 pr_list_bugs = []
79 pr_list_dependencies = []
80 pr_list_core = []
81 pr_list_keyboards = []
82 pr_list_keyboard_fixes = []
83 pr_list_cli = []
84 pr_list_others = []
85
86 def _categorise_commit(commit_info):
87 def fix_or_normal(info, fixes_collection, normal_collection):
88 if "bug" in info['pr_labels'] or fix_expr.search(info['title']):
89 fixes_collection.append(info)
90 else:
91 normal_collection.append(info)
92
93 if _is_ignored(commit_info['title']):
94 return
95 elif "dependencies" in commit_info['pr_labels']:
96 fix_or_normal(commit_info, pr_list_bugs, pr_list_dependencies)
97 elif "core" in commit_info['pr_labels']:
98 fix_or_normal(commit_info, pr_list_bugs, pr_list_core)
99 elif "keyboard" in commit_info['pr_labels'] or "keymap" in commit_info['pr_labels'] or "via" in commit_info['pr_labels']:
100 fix_or_normal(commit_info, pr_list_keyboard_fixes, pr_list_keyboards)
101 elif "cli" in commit_info['pr_labels']:
102 fix_or_normal(commit_info, pr_list_bugs, pr_list_cli)
103 else:
104 fix_or_normal(commit_info, pr_list_bugs, pr_list_others)
105
106 git_expr = re.compile(r'^(?P<hash>[a-f0-9]+) (?P<title>.*) \(#(?P<pr>[0-9]+)\)$')
107 for line in commit_list.stdout.split('\n'):
108 match = git_expr.search(line)
109 if match:
110 pr_info = _get_pr_info(cache, gh, match.group("pr"))
111 commit_info = {'hash': match.group("hash"), 'title': pr_info['title'], 'pr_num': int(match.group("pr")), 'pr_labels': [label.name for label in pr_info.labels.items]}
112 _categorise_commit(commit_info)
113
114 def _dump_commit_list(name, collection):
115 if len(collection) == 0:
116 return
117 print("")
118 print(f"{name}:")
119 for commit in sorted(collection, key=lambda x: x['pr_num']):
120 title = clean1_expr.sub('', clean2_expr.sub('', commit['title'])).strip()
121 pr_num = commit['pr_num']
122 print(f'* {title} ([#{pr_num}](https://github.com/qmk/qmk_firmware/pull/{pr_num}))')
123
124 _dump_commit_list("Core", pr_list_core)
125 _dump_commit_list("CLI", pr_list_cli)
126 _dump_commit_list("Submodule updates", pr_list_dependencies)
127 _dump_commit_list("Keyboards", pr_list_keyboards)
128 _dump_commit_list("Keyboard fixes", pr_list_keyboard_fixes)
129 _dump_commit_list("Others", pr_list_others)
130 _dump_commit_list("Bugs", pr_list_bugs)
diff --git a/lib/python/qmk/cli/generate/dfu_header.py b/lib/python/qmk/cli/generate/dfu_header.py
new file mode 100644
index 0000000000..aa0252ca86
--- /dev/null
+++ b/lib/python/qmk/cli/generate/dfu_header.py
@@ -0,0 +1,50 @@
1"""Used by the make system to generate LUFA Keyboard.h from info.json
2"""
3from dotty_dict import dotty
4from milc import cli
5
6from qmk.decorators import automagic_keyboard
7from qmk.info import info_json
8from qmk.path import is_keyboard, normpath
9from qmk.keyboard import keyboard_completer
10from qmk.commands import dump_lines
11from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
12
13
14@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
15@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
16@cli.argument('-kb', '--keyboard', completer=keyboard_completer, help='Keyboard to generate LUFA Keyboard.h for.')
17@cli.subcommand('Used by the make system to generate LUFA Keyboard.h from info.json', hidden=True)
18@automagic_keyboard
19def generate_dfu_header(cli):
20 """Generates the Keyboard.h file.
21 """
22 # Determine our keyboard(s)
23 if not cli.config.generate_dfu_header.keyboard:
24 cli.log.error('Missing parameter: --keyboard')
25 cli.subcommands['info'].print_help()
26 return False
27
28 if not is_keyboard(cli.config.generate_dfu_header.keyboard):
29 cli.log.error('Invalid keyboard: "%s"', cli.config.generate_dfu_header.keyboard)
30 return False
31
32 # Build the Keyboard.h file.
33 kb_info_json = dotty(info_json(cli.config.generate_dfu_header.keyboard))
34
35 keyboard_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once']
36 keyboard_h_lines.append(f'#define MANUFACTURER "{kb_info_json["manufacturer"]}"')
37 keyboard_h_lines.append(f'#define PRODUCT "{kb_info_json["keyboard_name"]} Bootloader"')
38
39 # Optional
40 if 'qmk_lufa_bootloader.esc_output' in kb_info_json:
41 keyboard_h_lines.append(f'#define QMK_ESC_OUTPUT {kb_info_json["qmk_lufa_bootloader.esc_output"]}')
42 if 'qmk_lufa_bootloader.esc_input' in kb_info_json:
43 keyboard_h_lines.append(f'#define QMK_ESC_INPUT {kb_info_json["qmk_lufa_bootloader.esc_input"]}')
44 if 'qmk_lufa_bootloader.led' in kb_info_json:
45 keyboard_h_lines.append(f'#define QMK_LED {kb_info_json["qmk_lufa_bootloader.led"]}')
46 if 'qmk_lufa_bootloader.speaker' in kb_info_json:
47 keyboard_h_lines.append(f'#define QMK_SPEAKER {kb_info_json["qmk_lufa_bootloader.speaker"]}')
48
49 # Show the results
50 dump_lines(cli.args.output, keyboard_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/docs.py b/lib/python/qmk/cli/generate/docs.py
new file mode 100644
index 0000000000..7abeca9d2a
--- /dev/null
+++ b/lib/python/qmk/cli/generate/docs.py
@@ -0,0 +1,34 @@
1"""Build QMK documentation locally
2"""
3import shutil
4from qmk.docs import prepare_docs_build_area, run_docs_command, BUILD_DOCS_PATH
5
6from milc import cli
7
8
9@cli.argument('-s', '--serve', arg_only=True, action='store_true', help="Serves the generated docs once built.")
10@cli.subcommand('Build QMK documentation.', hidden=False if cli.config.user.developer else True)
11def generate_docs(cli):
12 """Invoke the docs generation process
13
14 TODO(unclaimed):
15 * [ ] Add a real build step... something static docs
16 """
17
18 if not shutil.which('doxygen'):
19 cli.log.error('doxygen is not installed. Please install it and try again.')
20 return
21
22 if not shutil.which('yarn'):
23 cli.log.error('yarn is not installed. Please install it and try again.')
24 return
25
26 if not prepare_docs_build_area(is_production=True):
27 return False
28
29 cli.log.info('Building vitepress docs')
30 run_docs_command('run', ['docs:build'])
31 cli.log.info('Successfully generated docs to %s.', BUILD_DOCS_PATH)
32
33 if cli.args.serve:
34 run_docs_command('run', ['docs:preview'])
diff --git a/lib/python/qmk/cli/generate/info_json.py b/lib/python/qmk/cli/generate/info_json.py
new file mode 100755
index 0000000000..08c294146b
--- /dev/null
+++ b/lib/python/qmk/cli/generate/info_json.py
@@ -0,0 +1,93 @@
1"""Keyboard information script.
2
3Compile an info.json for a particular keyboard and pretty-print it.
4"""
5import json
6
7from argcomplete.completers import FilesCompleter
8from jsonschema import Draft202012Validator, RefResolver, validators
9from milc import cli
10from pathlib import Path
11
12from qmk.decorators import automagic_keyboard, automagic_keymap
13from qmk.info import info_json
14from qmk.json_encoders import InfoJSONEncoder
15from qmk.json_schema import compile_schema_store
16from qmk.keyboard import keyboard_completer, keyboard_folder
17from qmk.path import is_keyboard, normpath
18
19
20def pruning_validator(validator_class):
21 """Extends Draft202012Validator to remove properties that aren't specified in the schema.
22 """
23 validate_properties = validator_class.VALIDATORS["properties"]
24
25 def remove_additional_properties(validator, properties, instance, schema):
26 for prop in list(instance.keys()):
27 if prop not in properties:
28 del instance[prop]
29
30 for error in validate_properties(validator, properties, instance, schema):
31 yield error
32
33 return validators.extend(validator_class, {"properties": remove_additional_properties})
34
35
36def strip_info_json(kb_info_json):
37 """Remove the API-only properties from the info.json.
38 """
39 schema_store = compile_schema_store()
40 pruning_draft_validator = pruning_validator(Draft202012Validator)
41 schema = schema_store['qmk.keyboard.v1']
42 resolver = RefResolver.from_schema(schema_store['qmk.keyboard.v1'], store=schema_store)
43 validator = pruning_draft_validator(schema, resolver=resolver).validate
44
45 return validator(kb_info_json)
46
47
48@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='Keyboard to show info for.')
49@cli.argument('-km', '--keymap', help='Show the layers for a JSON keymap too.')
50@cli.argument('-o', '--output', arg_only=True, completer=FilesCompleter, help='Write the output the specified file, overwriting if necessary.')
51@cli.argument('-ow', '--overwrite', arg_only=True, action='store_true', help='Overwrite the existing info.json. (Overrides the location of --output)')
52@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
53@automagic_keyboard
54@automagic_keymap
55def generate_info_json(cli):
56 """Generate an info.json file for a keyboard
57 """
58 # Determine our keyboard(s)
59 if not cli.config.generate_info_json.keyboard:
60 cli.log.error('Missing parameter: --keyboard')
61 cli.subcommands['info'].print_help()
62 return False
63
64 if not is_keyboard(cli.config.generate_info_json.keyboard):
65 cli.log.error('Invalid keyboard: "%s"', cli.config.generate_info_json.keyboard)
66 return False
67
68 if cli.args.overwrite:
69 output_path = (Path('keyboards') / cli.config.generate_info_json.keyboard / 'info.json').resolve()
70
71 if cli.args.output:
72 cli.log.warning('Overwriting user supplied --output with %s', output_path)
73
74 cli.args.output = output_path
75
76 # Build the info.json file
77 kb_info_json = info_json(cli.config.generate_info_json.keyboard)
78 strip_info_json(kb_info_json)
79 info_json_text = json.dumps(kb_info_json, indent=4, cls=InfoJSONEncoder, sort_keys=True)
80
81 if cli.args.output:
82 # Write to a file
83 output_path = normpath(cli.args.output)
84
85 if output_path.exists():
86 cli.log.warning('Overwriting output file %s', output_path)
87
88 output_path.write_text(info_json_text + '\n')
89 cli.log.info('Wrote info.json to %s.', output_path)
90
91 else:
92 # Display the results
93 print(info_json_text)
diff --git a/lib/python/qmk/cli/generate/keyboard_c.py b/lib/python/qmk/cli/generate/keyboard_c.py
new file mode 100755
index 0000000000..1978de4a22
--- /dev/null
+++ b/lib/python/qmk/cli/generate/keyboard_c.py
@@ -0,0 +1,274 @@
1"""Used by the make system to generate keyboard.c from info.json.
2"""
3import bisect
4import dataclasses
5from typing import Optional
6
7from milc import cli
8
9from qmk.info import info_json
10from qmk.commands import dump_lines
11from qmk.keyboard import keyboard_completer, keyboard_folder
12from qmk.path import normpath
13from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, JOYSTICK_AXES
14
15
16def _gen_led_configs(info_data):
17 lines = []
18
19 if 'layout' in info_data.get('rgb_matrix', {}):
20 lines.extend(_gen_led_config(info_data, 'rgb_matrix'))
21
22 if 'layout' in info_data.get('led_matrix', {}):
23 lines.extend(_gen_led_config(info_data, 'led_matrix'))
24
25 return lines
26
27
28def _gen_led_config(info_data, config_type):
29 """Convert info.json content to g_led_config
30 """
31 cols = info_data['matrix_size']['cols']
32 rows = info_data['matrix_size']['rows']
33
34 lines = []
35
36 matrix = [['NO_LED'] * cols for _ in range(rows)]
37 pos = []
38 flags = []
39
40 led_layout = info_data[config_type]['layout']
41 for index, led_data in enumerate(led_layout):
42 if 'matrix' in led_data:
43 row, col = led_data['matrix']
44 matrix[row][col] = str(index)
45 pos.append(f'{{{led_data.get("x", 0)}, {led_data.get("y", 0)}}}')
46 flags.append(str(led_data.get('flags', 0)))
47
48 if config_type == 'rgb_matrix':
49 lines.append('#ifdef RGB_MATRIX_ENABLE')
50 lines.append('#include "rgb_matrix.h"')
51 elif config_type == 'led_matrix':
52 lines.append('#ifdef LED_MATRIX_ENABLE')
53 lines.append('#include "led_matrix.h"')
54
55 lines.append('__attribute__ ((weak)) led_config_t g_led_config = {')
56 lines.append(' {')
57 for line in matrix:
58 lines.append(f' {{ {", ".join(line)} }},')
59 lines.append(' },')
60 lines.append(f' {{ {", ".join(pos)} }},')
61 lines.append(f' {{ {", ".join(flags)} }},')
62 lines.append('};')
63 lines.append('#endif')
64 lines.append('')
65
66 return lines
67
68
69def _gen_matrix_mask(info_data):
70 """Convert info.json content to matrix_mask
71 """
72 cols = info_data['matrix_size']['cols']
73 rows = info_data['matrix_size']['rows']
74
75 # Default mask to everything disabled
76 mask = [['0'] * cols for _ in range(rows)]
77
78 # Mirror layout macros squashed on top of each other
79 for layout_name, layout_data in info_data['layouts'].items():
80 for key_data in layout_data['layout']:
81 row, col = key_data['matrix']
82 if row >= rows or col >= cols:
83 cli.log.error(f'Skipping matrix_mask due to {layout_name} containing invalid matrix values')
84 return []
85 mask[row][col] = '1'
86
87 lines = []
88 lines.append('#ifdef MATRIX_MASKED')
89 lines.append('__attribute__((weak)) const matrix_row_t matrix_mask[] = {')
90 for i in range(rows):
91 lines.append(f' 0b{"".join(reversed(mask[i]))},')
92 lines.append('};')
93 lines.append('#endif')
94 lines.append('')
95
96 return lines
97
98
99def _gen_joystick_axes(info_data):
100 """Convert info.json content to joystick_axes
101 """
102 if 'axes' not in info_data.get('joystick', {}):
103 return []
104
105 axes = info_data['joystick']['axes']
106 axes_keys = list(axes.keys())
107
108 lines = []
109 lines.append('#ifdef JOYSTICK_ENABLE')
110 lines.append('joystick_config_t joystick_axes[JOYSTICK_AXIS_COUNT] = {')
111
112 # loop over all available axes - injecting virtual axis for those not specified
113 for index, cur in enumerate(JOYSTICK_AXES):
114 # bail out if we have generated all requested axis
115 if len(axes_keys) == 0:
116 break
117
118 axis = 'virtual'
119 if cur in axes:
120 axis = axes[cur]
121 axes_keys.remove(cur)
122
123 if axis == 'virtual':
124 lines.append(f" [{index}] = JOYSTICK_AXIS_VIRTUAL,")
125 else:
126 lines.append(f" [{index}] = JOYSTICK_AXIS_IN({axis['input_pin']}, {axis['low']}, {axis['rest']}, {axis['high']}),")
127
128 lines.append('};')
129 lines.append('#endif')
130 lines.append('')
131
132 return lines
133
134
135@dataclasses.dataclass
136class LayoutKey:
137 """Geometric info for one key in a layout."""
138 row: int
139 col: int
140 x: float
141 y: float
142 w: float = 1.0
143 h: float = 1.0
144 hand: Optional[str] = None
145
146 @staticmethod
147 def from_json(key_json):
148 row, col = key_json['matrix']
149 return LayoutKey(
150 row=row,
151 col=col,
152 x=key_json['x'],
153 y=key_json['y'],
154 w=key_json.get('w', 1.0),
155 h=key_json.get('h', 1.0),
156 hand=key_json.get('hand', None),
157 )
158
159 @property
160 def cx(self):
161 """Center x coordinate of the key."""
162 return self.x + self.w / 2.0
163
164 @property
165 def cy(self):
166 """Center y coordinate of the key."""
167 return self.y + self.h / 2.0
168
169
170class Layout:
171 """Geometric info of a layout."""
172 def __init__(self, layout_json):
173 self.keys = [LayoutKey.from_json(key_json) for key_json in layout_json['layout']]
174 self.x_min = min(key.cx for key in self.keys)
175 self.x_max = max(key.cx for key in self.keys)
176 self.x_mid = (self.x_min + self.x_max) / 2
177 # If there is one key with width >= 6u, it is probably the spacebar.
178 i = [i for i, key in enumerate(self.keys) if key.w >= 6.0]
179 self.spacebar = self.keys[i[0]] if len(i) == 1 else None
180
181 def is_symmetric(self, tol: float = 0.02):
182 """Whether the key positions are symmetric about x_mid."""
183 x = sorted([key.cx for key in self.keys])
184 for i in range(len(x)):
185 x_i_mirrored = 2.0 * self.x_mid - x[i]
186 # Find leftmost x element greater than or equal to (x_i_mirrored - tol).
187 j = bisect.bisect_left(x, x_i_mirrored - tol)
188 if j == len(x) or abs(x[j] - x_i_mirrored) > tol:
189 return False
190
191 return True
192
193 def widest_horizontal_gap(self):
194 """Finds the x midpoint of the widest horizontal gap between keys."""
195 x = sorted([key.cx for key in self.keys])
196 x_mid = self.x_mid
197 max_sep = 0
198 for i in range(len(x) - 1):
199 sep = x[i + 1] - x[i]
200 if sep > max_sep:
201 max_sep = sep
202 x_mid = (x[i + 1] + x[i]) / 2
203
204 return x_mid
205
206
207def _gen_chordal_hold_layout(info_data):
208 """Convert info.json content to chordal_hold_layout
209 """
210 # NOTE: If there are multiple layouts, only the first is read.
211 for layout_name, layout_json in info_data['layouts'].items():
212 layout = Layout(layout_json)
213 break
214
215 if layout.is_symmetric():
216 # If the layout is symmetric (e.g. most split keyboards), guess the
217 # handedness based on the sign of (x - layout.x_mid).
218 hand_signs = [key.x - layout.x_mid for key in layout.keys]
219 elif layout.spacebar is not None:
220 # If the layout has a spacebar, form a dividing line through the spacebar,
221 # nearly vertical but with a slight angle to follow typical row stagger.
222 x0 = layout.spacebar.cx - 0.05
223 y0 = layout.spacebar.cy - 1.0
224 hand_signs = [(key.x - x0) - (key.y - y0) / 3.0 for key in layout.keys]
225 else:
226 # Fallback: assume handedness based on the widest horizontal separation.
227 x_mid = layout.widest_horizontal_gap()
228 hand_signs = [key.x - x_mid for key in layout.keys]
229
230 for key, hand_sign in zip(layout.keys, hand_signs):
231 if key.hand is None:
232 if key == layout.spacebar or abs(hand_sign) <= 0.02:
233 key.hand = '*'
234 else:
235 key.hand = 'L' if hand_sign < 0.0 else 'R'
236
237 lines = []
238 lines.append('#ifdef CHORDAL_HOLD')
239 line = ('__attribute__((weak)) const char chordal_hold_layout[MATRIX_ROWS][MATRIX_COLS] PROGMEM = ' + layout_name + '(')
240
241 x_prev = None
242 for key in layout.keys:
243 if x_prev is None or key.x < x_prev:
244 lines.append(line)
245 line = ' '
246 line += f"'{key.hand}', "
247 x_prev = key.x
248
249 lines.append(line[:-2])
250 lines.append(');')
251 lines.append('#endif')
252
253 return lines
254
255
256@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
257@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
258@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, required=True, help='Keyboard to generate keyboard.c for.')
259@cli.subcommand('Used by the make system to generate keyboard.c from info.json', hidden=True)
260def generate_keyboard_c(cli):
261 """Generates the keyboard.h file.
262 """
263 kb_info_json = info_json(cli.args.keyboard)
264
265 # Build the layouts.h file.
266 keyboard_c_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#include QMK_KEYBOARD_H', '']
267
268 keyboard_c_lines.extend(_gen_led_configs(kb_info_json))
269 keyboard_c_lines.extend(_gen_matrix_mask(kb_info_json))
270 keyboard_c_lines.extend(_gen_joystick_axes(kb_info_json))
271 keyboard_c_lines.extend(_gen_chordal_hold_layout(kb_info_json))
272
273 # Show the results
274 dump_lines(cli.args.output, keyboard_c_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/keyboard_h.py b/lib/python/qmk/cli/generate/keyboard_h.py
new file mode 100755
index 0000000000..cb9528d96b
--- /dev/null
+++ b/lib/python/qmk/cli/generate/keyboard_h.py
@@ -0,0 +1,130 @@
1"""Used by the make system to generate keyboard.h from info.json.
2"""
3from pathlib import Path
4
5from milc import cli
6
7from qmk.path import normpath
8from qmk.info import info_json
9from qmk.commands import dump_lines
10from qmk.keyboard import keyboard_completer, keyboard_folder
11from qmk.constants import COL_LETTERS, ROW_LETTERS, GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
12
13
14def _generate_layouts(keyboard, kb_info_json):
15 """Generates the layouts macros.
16 """
17 if 'matrix_size' not in kb_info_json:
18 cli.log.error(f'{keyboard}: Invalid matrix config.')
19 return []
20
21 col_num = kb_info_json['matrix_size']['cols']
22 row_num = kb_info_json['matrix_size']['rows']
23
24 lines = []
25 lines.append('')
26 lines.append('// Layout content')
27 lines.append('')
28 lines.append('#define XXX KC_NO')
29
30 for layout_name, layout_data in kb_info_json['layouts'].items():
31 if layout_data['c_macro']:
32 continue
33
34 if not all('matrix' in key_data for key_data in layout_data['layout']):
35 cli.log.debug(f'{keyboard}/{layout_name}: No or incomplete matrix data!')
36 continue
37
38 layout_keys = []
39 layout_matrix = [['XXX'] * col_num for _ in range(row_num)]
40
41 for key_data in layout_data['layout']:
42 row, col = key_data['matrix']
43 identifier = f'k{ROW_LETTERS[row]}{COL_LETTERS[col]}'
44 if row >= row_num or col >= col_num:
45 cli.log.error(f'Skipping layouts due to {layout_name} containing invalid matrix values')
46 return []
47
48 layout_matrix[row][col] = identifier
49 layout_keys.append(identifier)
50
51 lines.append('')
52 lines.append(f'#define {layout_name}({", ".join(layout_keys)}) {{ \\')
53
54 rows = ', \\\n'.join([' { ' + ', '.join(row) + ' }' for row in layout_matrix])
55 rows += ' \\'
56 lines.append(rows)
57 lines.append('}')
58
59 for alias, target in kb_info_json.get('layout_aliases', {}).items():
60 lines.append('')
61 lines.append(f'#ifndef {alias}')
62 lines.append(f'# define {alias} {target}')
63 lines.append('#endif')
64
65 return lines
66
67
68def _generate_keycodes(kb_info_json):
69 """Generates keyboard level keycodes.
70 """
71 if 'keycodes' not in kb_info_json:
72 return []
73
74 lines = []
75 lines.append('')
76 lines.append('// Keycode content')
77 lines.append('')
78 lines.append('enum keyboard_keycodes {')
79
80 for index, item in enumerate(kb_info_json.get('keycodes')):
81 key = item["key"]
82 if index == 0:
83 lines.append(f' {key} = QK_KB_0,')
84 else:
85 lines.append(f' {key},')
86
87 lines.append('};')
88
89 for item in kb_info_json.get('keycodes', []):
90 key = item["key"]
91 for alias in item.get("aliases", []):
92 lines.append(f'#define {alias} {key}')
93
94 return lines
95
96
97@cli.argument('-i', '--include', nargs='?', arg_only=True, help='Optional file to include')
98@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
99@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
100@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, required=True, help='Keyboard to generate keyboard.h for.')
101@cli.subcommand('Used by the make system to generate keyboard.h from info.json', hidden=True)
102def generate_keyboard_h(cli):
103 """Generates the keyboard.h file.
104 """
105 # Build the info.json file
106 kb_info_json = info_json(cli.args.keyboard)
107
108 keyboard_h = cli.args.include
109 dd_layouts = _generate_layouts(cli.args.keyboard, kb_info_json)
110 dd_keycodes = _generate_keycodes(kb_info_json)
111 valid_config = dd_layouts or keyboard_h
112
113 # Build the layouts.h file.
114 keyboard_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once', '', '#include "quantum.h"']
115
116 if dd_layouts:
117 keyboard_h_lines.extend(dd_layouts)
118
119 if keyboard_h:
120 keyboard_h_lines.append(f'#include "{Path(keyboard_h).name}"')
121
122 if dd_keycodes:
123 keyboard_h_lines.extend(dd_keycodes)
124
125 # Protect against poorly configured keyboards
126 if not valid_config:
127 keyboard_h_lines.append('#error("<keyboard>.h is required unless your keyboard uses data-driven configuration. Please rename your keyboard\'s header file to <keyboard>.h")')
128
129 # Show the results
130 dump_lines(cli.args.output, keyboard_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/keycodes.py b/lib/python/qmk/cli/generate/keycodes.py
new file mode 100644
index 0000000000..d694202aec
--- /dev/null
+++ b/lib/python/qmk/cli/generate/keycodes.py
@@ -0,0 +1,182 @@
1"""Used by the make system to generate keycodes.h from keycodes_{version}.json
2"""
3from milc import cli
4
5from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
6from qmk.commands import dump_lines
7from qmk.path import normpath
8from qmk.keycodes import load_spec
9
10
11def _translate_group(group):
12 """Fix up any issues with badly chosen values
13 """
14 if group == 'modifiers':
15 return 'modifier'
16 if group == 'media':
17 return 'consumer'
18 return group
19
20
21def _render_key(key):
22 width = 7
23 if 'S(' in key:
24 width += len('S()')
25 if 'A(' in key:
26 width += len('A()')
27 if 'RCTL(' in key:
28 width += len('RCTL()')
29 if 'ALGR(' in key:
30 width += len('ALGR()')
31 return key.ljust(width)
32
33
34def _render_label(label):
35 label = label.replace("\\", "(backslash)")
36 return label
37
38
39def _generate_ranges(lines, keycodes):
40 lines.append('')
41 lines.append('enum qk_keycode_ranges {')
42 lines.append('// Ranges')
43 for key, value in keycodes["ranges"].items():
44 lo, mask = map(lambda x: int(x, 16), key.split("/"))
45 hi = lo + mask
46 define = value.get("define")
47 lines.append(f' {define.ljust(30)} = 0x{lo:04X},')
48 lines.append(f' {(define + "_MAX").ljust(30)} = 0x{hi:04X},')
49 lines.append('};')
50
51
52def _generate_defines(lines, keycodes):
53 lines.append('')
54 lines.append('enum qk_keycode_defines {')
55 lines.append('// Keycodes')
56 for key, value in keycodes["keycodes"].items():
57 lines.append(f' {value.get("key")} = {key},')
58
59 lines.append('')
60 lines.append('// Alias')
61 for key, value in keycodes["keycodes"].items():
62 temp = value.get("key")
63 for alias in value.get("aliases", []):
64 lines.append(f' {alias.ljust(10)} = {temp},')
65
66 lines.append('};')
67
68
69def _generate_helpers(lines, keycodes):
70 lines.append('')
71 lines.append('// Range Helpers')
72 for value in keycodes["ranges"].values():
73 define = value.get("define")
74 lines.append(f'#define IS_{define}(code) ((code) >= {define} && (code) <= {define + "_MAX"})')
75
76 # extract min/max
77 temp = {}
78 for key, value in keycodes["keycodes"].items():
79 group = value.get('group', None)
80 if not group:
81 continue
82 if group not in temp:
83 temp[group] = [0xFFFF, 0]
84 key = int(key, 16)
85 if key < temp[group][0]:
86 temp[group][0] = key
87 if key > temp[group][1]:
88 temp[group][1] = key
89
90 lines.append('')
91 lines.append('// Group Helpers')
92 for group, codes in temp.items():
93 lo = keycodes["keycodes"][f'0x{codes[0]:04X}']['key']
94 hi = keycodes["keycodes"][f'0x{codes[1]:04X}']['key']
95 lines.append(f'#define IS_{_translate_group(group).upper()}_KEYCODE(code) ((code) >= {lo} && (code) <= {hi})')
96
97 lines.append('')
98 lines.append('// Switch statement Helpers')
99 for group, codes in temp.items():
100 lo = keycodes["keycodes"][f'0x{codes[0]:04X}']['key']
101 hi = keycodes["keycodes"][f'0x{codes[1]:04X}']['key']
102 name = f'{_translate_group(group).upper()}_KEYCODE_RANGE'
103 lines.append(f'#define {name.ljust(35)} {lo} ... {hi}')
104
105
106def _generate_aliases(lines, keycodes):
107 # Work around ChibiOS ch.h include guard
108 if 'CH_H' in [value['key'] for value in keycodes['aliases'].values()]:
109 lines.append('')
110 lines.append('#undef CH_H')
111
112 lines.append('')
113 lines.append('// Aliases')
114 for key, value in keycodes["aliases"].items():
115 define = _render_key(value.get("key"))
116 val = _render_key(key)
117 if 'label' in value:
118 lines.append(f'#define {define} {val} // {_render_label(value.get("label"))}')
119 else:
120 lines.append(f'#define {define} {val}')
121
122 lines.append('')
123 for key, value in keycodes["aliases"].items():
124 for alias in value.get("aliases", []):
125 lines.append(f'#define {alias} {value.get("key")}')
126
127
128def _generate_version(lines, keycodes, prefix=''):
129 version = keycodes['version']
130 major, minor, patch = map(int, version.split('.'))
131
132 bcd = f'0x{major:02d}{minor:02d}{patch:04d}'
133
134 lines.append('')
135 lines.append(f'#define QMK_{prefix}KEYCODES_VERSION "{version}"')
136 lines.append(f'#define QMK_{prefix}KEYCODES_VERSION_BCD {bcd}')
137 lines.append(f'#define QMK_{prefix}KEYCODES_VERSION_MAJOR {major}')
138 lines.append(f'#define QMK_{prefix}KEYCODES_VERSION_MINOR {minor}')
139 lines.append(f'#define QMK_{prefix}KEYCODES_VERSION_PATCH {patch}')
140
141
142@cli.argument('-v', '--version', arg_only=True, required=True, help='Version of keycodes to generate.')
143@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
144@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
145@cli.subcommand('Used by the make system to generate keycodes.h from keycodes_{version}.json', hidden=True)
146def generate_keycodes(cli):
147 """Generates the keycodes.h file.
148 """
149
150 # Build the keycodes.h file.
151 keycodes_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once', '// clang-format off']
152
153 keycodes = load_spec(cli.args.version)
154
155 _generate_version(keycodes_h_lines, keycodes)
156 _generate_ranges(keycodes_h_lines, keycodes)
157 _generate_defines(keycodes_h_lines, keycodes)
158 _generate_helpers(keycodes_h_lines, keycodes)
159
160 # Show the results
161 dump_lines(cli.args.output, keycodes_h_lines, cli.args.quiet)
162
163
164@cli.argument('-v', '--version', arg_only=True, required=True, help='Version of keycodes to generate.')
165@cli.argument('-l', '--lang', arg_only=True, required=True, help='Language of keycodes to generate.')
166@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
167@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
168@cli.subcommand('Used by the make system to generate keymap_{lang}.h from keycodes_{lang}_{version}.json', hidden=True)
169def generate_keycode_extras(cli):
170 """Generates the header file.
171 """
172
173 # Build the header file.
174 keycodes_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once', '#include "keycodes.h"', '// clang-format off']
175
176 keycodes = load_spec(cli.args.version, cli.args.lang)
177
178 _generate_version(keycodes_h_lines, keycodes, f'{cli.args.lang.upper()}_')
179 _generate_aliases(keycodes_h_lines, keycodes)
180
181 # Show the results
182 dump_lines(cli.args.output, keycodes_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/keymap_h.py b/lib/python/qmk/cli/generate/keymap_h.py
new file mode 100644
index 0000000000..a3aaa405c0
--- /dev/null
+++ b/lib/python/qmk/cli/generate/keymap_h.py
@@ -0,0 +1,51 @@
1from argcomplete.completers import FilesCompleter
2
3from milc import cli
4
5import qmk.path
6from qmk.commands import dump_lines
7from qmk.commands import parse_configurator_json
8from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
9
10
11def _generate_keycodes_function(keymap_json):
12 """Generates keymap level keycodes.
13 """
14 lines = []
15 lines.append('enum keymap_keycodes {')
16
17 for index, item in enumerate(keymap_json.get('keycodes', [])):
18 key = item["key"]
19 if index == 0:
20 lines.append(f' {key} = QK_USER_0,')
21 else:
22 lines.append(f' {key},')
23
24 lines.append('};')
25
26 for item in keymap_json.get('keycodes', []):
27 key = item["key"]
28 for alias in item.get("aliases", []):
29 lines.append(f'#define {alias} {key}')
30
31 return lines
32
33
34@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
35@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
36@cli.argument('filename', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
37@cli.subcommand('Creates a keymap.h from a QMK Configurator export.')
38def generate_keymap_h(cli):
39 """Creates a keymap.h from a QMK Configurator export
40 """
41 if cli.args.output and cli.args.output.name == '-':
42 cli.args.output = None
43
44 keymap_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once', '// clang-format off']
45
46 keymap_json = parse_configurator_json(cli.args.filename)
47
48 if 'keycodes' in keymap_json and keymap_json['keycodes'] is not None:
49 keymap_h_lines += _generate_keycodes_function(keymap_json)
50
51 dump_lines(cli.args.output, keymap_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/make_dependencies.py b/lib/python/qmk/cli/generate/make_dependencies.py
new file mode 100755
index 0000000000..9548187888
--- /dev/null
+++ b/lib/python/qmk/cli/generate/make_dependencies.py
@@ -0,0 +1,56 @@
1"""Used by the make system to generate dependency lists for each of the generated files.
2"""
3from pathlib import Path
4from milc import cli
5
6from argcomplete.completers import FilesCompleter
7
8from qmk.commands import dump_lines
9from qmk.keyboard import keyboard_completer, keyboard_folder
10from qmk.keymap import keymap_completer, locate_keymap
11from qmk.path import normpath, FileType, unix_style_path
12
13
14@cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), completer=FilesCompleter('.json'), help='A configurator export JSON.')
15@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
16@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
17@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, required=True, help='Keyboard to generate dependency file for.')
18@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
19@cli.subcommand('Generates the list of dependencies associated with a keyboard build and its generated files.', hidden=True)
20def generate_make_dependencies(cli):
21 """Generates the list of dependent config files for a keyboard.
22 """
23 interesting_files = [
24 'info.json',
25 'keyboard.json',
26 'rules.mk',
27 'post_rules.mk',
28 'config.h',
29 'post_config.h',
30 ]
31
32 check_files = []
33
34 # Walk up the keyboard's directory tree looking for the files we're interested in
35 keyboards_root = Path('keyboards')
36 parent_path = Path('keyboards') / cli.args.keyboard
37 while parent_path != keyboards_root:
38 for file in interesting_files:
39 check_files.append(parent_path / file)
40 parent_path = parent_path.parent
41
42 # Find the keymap and include any of the interesting files
43 if cli.args.keymap is not None:
44 km = locate_keymap(cli.args.keyboard, cli.args.keymap)
45 if km is not None:
46 # keymap.json is only valid for the keymap, so check this one separately
47 check_files.append(km.parent / 'keymap.json')
48 # Add all the interesting files
49 for file in interesting_files:
50 check_files.append(km.parent / file)
51
52 # If we have a matching userspace, include those too
53 for file in interesting_files:
54 check_files.append(Path('users') / cli.args.keymap / file)
55
56 dump_lines(cli.args.output, [f'generated-files: $(wildcard {unix_style_path(found)})\n' for found in check_files])
diff --git a/lib/python/qmk/cli/generate/rgb_breathe_table.py b/lib/python/qmk/cli/generate/rgb_breathe_table.py
new file mode 100644
index 0000000000..55c80f6015
--- /dev/null
+++ b/lib/python/qmk/cli/generate/rgb_breathe_table.py
@@ -0,0 +1,78 @@
1"""Generate rgblight_breathe_table.h
2"""
3import math
4from argparse import ArgumentTypeError
5
6from milc import cli
7
8from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
9from qmk.commands import dump_lines
10from qmk.path import normpath
11
12
13def breathing_center(value):
14 value = float(value)
15 if value >= 1 and value <= 2.7:
16 return value
17 else:
18 raise ArgumentTypeError('Breathing center must be between 1 and 2.7')
19
20
21def breathing_max(value):
22 value = int(value)
23 if value in range(0, 256):
24 return value
25 else:
26 raise ArgumentTypeError('Breathing max must be between 0 and 255')
27
28
29def _generate_table(lines, center, maximum):
30 breathe_values = [0] * 256
31 for pos in range(0, 256):
32 breathe_values[pos] = (int)((math.exp(math.sin((pos / 255) * math.pi)) - center / math.e) * (maximum / (math.e - 1 / math.e)))
33
34 values_template = ''
35 for s in range(0, 3):
36 step = 1 << s
37
38 values_template += '#if RGBLIGHT_BREATHE_TABLE_SIZE == {}\n'.format(256 >> s)
39
40 for pos in range(0, 256, step):
41 values_template += ' ' if pos % 8 == 0 else ''
42 values_template += '0x{:02X}'.format(breathe_values[pos])
43 values_template += ',' if (pos + step) < 256 else ''
44 values_template += '\n' if (pos + step) % 8 == 0 else ' '
45
46 values_template += '#endif'
47 values_template += '\n\n' if s < 2 else ''
48
49 table_template = '''#define RGBLIGHT_EFFECT_BREATHE_TABLE
50
51// Breathing center: {0:.2f}
52// Breathing max: {1:d}
53
54const uint8_t PROGMEM rgblight_effect_breathe_table[] = {{
55{2}
56}};
57
58static const int table_scale = 256 / sizeof(rgblight_effect_breathe_table);
59'''.format(center, maximum, values_template)
60 lines.append(table_template)
61
62
63@cli.argument('-c', '--center', arg_only=True, type=breathing_center, default=1.85, help='The breathing center value, from 1 to 2.7. Default: 1.85')
64@cli.argument('-m', '--max', arg_only=True, type=breathing_max, default=255, help='The breathing maximum value, from 0 to 255. Default: 255')
65@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
66@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help='Quiet mode, only output error messages')
67@cli.subcommand('Generates an RGB Light breathing table header.')
68def generate_rgb_breathe_table(cli):
69 """Generate a rgblight_breathe_table.h file containing a breathing LUT for RGB Lighting (Underglow) feature.
70 """
71
72 # Build the header file.
73 header_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once', '// clang-format off']
74
75 _generate_table(header_lines, cli.args.center, cli.args.max)
76
77 # Show the results
78 dump_lines(cli.args.output, header_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/generate/rules_mk.py b/lib/python/qmk/cli/generate/rules_mk.py
new file mode 100755
index 0000000000..16084bded1
--- /dev/null
+++ b/lib/python/qmk/cli/generate/rules_mk.py
@@ -0,0 +1,117 @@
1"""Used by the make system to generate a rules.mk
2"""
3from pathlib import Path
4from dotty_dict import dotty
5
6from argcomplete.completers import FilesCompleter
7from milc import cli
8
9from qmk.info import info_json
10from qmk.json_schema import json_load
11from qmk.keyboard import keyboard_completer, keyboard_folder
12from qmk.commands import dump_lines, parse_configurator_json
13from qmk.path import normpath, FileType
14from qmk.constants import GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE
15
16
17def generate_rule(rules_key, rules_value):
18 is_keymap = cli.args.filename
19 rule_assignment_operator = '=' if is_keymap else '?='
20 return f'{rules_key} {rule_assignment_operator} {rules_value}'
21
22
23def process_mapping_rule(kb_info_json, rules_key, info_dict):
24 """Return the rules.mk line(s) for a mapping rule.
25 """
26 if not info_dict.get('to_c', True):
27 return None
28
29 info_key = info_dict['info_key']
30 key_type = info_dict.get('value_type', 'raw')
31
32 try:
33 rules_value = kb_info_json[info_key]
34 except KeyError:
35 return None
36
37 if key_type in ['array', 'list']:
38 return generate_rule(rules_key, " ".join(rules_value))
39 elif key_type == 'bool':
40 return generate_rule(rules_key, "yes" if rules_value else "no")
41 elif key_type == 'mapping':
42 return '\n'.join([generate_rule(key, value) for key, value in rules_value.items()])
43 elif key_type == 'str':
44 return generate_rule(rules_key, f'"{rules_value}"')
45
46 return generate_rule(rules_key, rules_value)
47
48
49def generate_features_rules(features_dict):
50 lines = []
51 for feature, enabled in features_dict.items():
52 feature = feature.upper()
53 enabled = 'yes' if enabled else 'no'
54 lines.append(generate_rule(f'{feature}_ENABLE', enabled))
55 return lines
56
57
58@cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), completer=FilesCompleter('.json'), help='A configurator export JSON to be compiled and flashed or a pre-compiled binary firmware file (bin/hex) to be flashed.')
59@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
60@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
61@cli.argument('-e', '--escape', arg_only=True, action='store_true', help="Escape spaces in quiet mode")
62@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate rules.mk for.')
63@cli.subcommand('Used by the make system to generate rules.mk from info.json', hidden=True)
64def generate_rules_mk(cli):
65 """Generates a rules.mk file from info.json.
66 """
67 converter = None
68 # Determine our keyboard/keymap
69 if cli.args.filename:
70 user_keymap = parse_configurator_json(cli.args.filename)
71 kb_info_json = dotty(user_keymap.get('config', {}))
72 converter = user_keymap.get('converter', None)
73 elif cli.args.keyboard:
74 kb_info_json = dotty(info_json(cli.args.keyboard))
75 else:
76 cli.log.error('You must supply a configurator export or `--keyboard`.')
77 cli.subcommands['generate-rules-mk'].print_help()
78 return False
79
80 info_rules_map = json_load(Path('data/mappings/info_rules.hjson'))
81 rules_mk_lines = [GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE]
82
83 # Iterate through the info_rules map to generate basic rules
84 for rules_key, info_dict in info_rules_map.items():
85 new_entry = process_mapping_rule(kb_info_json, rules_key, info_dict)
86
87 if new_entry:
88 rules_mk_lines.append(new_entry)
89
90 # Iterate through features to enable/disable them
91 if 'features' in kb_info_json:
92 rules_mk_lines.extend(generate_features_rules(kb_info_json['features']))
93
94 # Set SPLIT_TRANSPORT, if needed
95 if kb_info_json.get('split', {}).get('transport', {}).get('protocol') == 'custom':
96 rules_mk_lines.append(generate_rule('SPLIT_TRANSPORT', 'custom'))
97
98 # Set CUSTOM_MATRIX, if needed
99 if kb_info_json.get('matrix_pins', {}).get('custom_lite'):
100 rules_mk_lines.append(generate_rule('CUSTOM_MATRIX', 'lite'))
101 elif kb_info_json.get('matrix_pins', {}).get('custom'):
102 rules_mk_lines.append(generate_rule('CUSTOM_MATRIX', 'yes'))
103
104 if converter:
105 rules_mk_lines.append(generate_rule('CONVERT_TO', converter))
106
107 # Show the results
108 dump_lines(cli.args.output, rules_mk_lines)
109
110 if cli.args.output:
111 if cli.args.quiet:
112 if cli.args.escape:
113 print(cli.args.output.as_posix().replace(' ', '\\ '))
114 else:
115 print(cli.args.output)
116 else:
117 cli.log.info('Wrote rules.mk to %s.', cli.args.output)
diff --git a/lib/python/qmk/cli/generate/version_h.py b/lib/python/qmk/cli/generate/version_h.py
new file mode 100644
index 0000000000..8156e85559
--- /dev/null
+++ b/lib/python/qmk/cli/generate/version_h.py
@@ -0,0 +1,62 @@
1"""Used by the make system to generate version.h for use in code.
2"""
3from time import strftime
4
5from milc import cli
6
7from qmk.path import normpath
8from qmk.commands import dump_lines
9from qmk.git import git_get_qmk_hash, git_get_version, git_is_dirty
10from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
11from qmk.util import triplet_to_bcd
12
13TIME_FMT = '%Y-%m-%d-%H:%M:%S'
14
15
16@cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
17@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
18@cli.argument('--skip-git', arg_only=True, action='store_true', help='Skip Git operations')
19@cli.argument('--skip-all', arg_only=True, action='store_true', help='Use placeholder values for all defines (implies --skip-git)')
20@cli.subcommand('Used by the make system to generate version.h for use in code', hidden=True)
21def generate_version_h(cli):
22 """Generates the version.h file.
23 """
24 if cli.args.skip_all:
25 cli.args.skip_git = True
26
27 if cli.args.skip_all:
28 current_time = "1970-01-01-00:00:00"
29 else:
30 current_time = strftime(TIME_FMT)
31
32 if cli.args.skip_git:
33 git_dirty = False
34 git_version = "NA"
35 git_qmk_hash = "NA"
36 git_bcd_version = "0x00000000"
37 chibios_version = "NA"
38 chibios_contrib_version = "NA"
39 else:
40 git_dirty = git_is_dirty()
41 git_version = git_get_version() or current_time
42 git_qmk_hash = git_get_qmk_hash() or "Unknown"
43 git_bcd_version = triplet_to_bcd(git_version)
44 chibios_version = git_get_version("chibios", "os") or current_time
45 chibios_contrib_version = git_get_version("chibios-contrib", "os") or current_time
46
47 # Build the version.h file.
48 version_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#pragma once']
49
50 version_h_lines.append(
51 f"""
52#define QMK_VERSION "{git_version}"
53#define QMK_BUILDDATE "{current_time}"
54#define QMK_VERSION_BCD {git_bcd_version}
55#define QMK_GIT_HASH "{git_qmk_hash}{'*' if git_dirty else ''}"
56#define CHIBIOS_VERSION "{chibios_version}"
57#define CHIBIOS_CONTRIB_VERSION "{chibios_contrib_version}"
58"""
59 )
60
61 # Show the results
62 dump_lines(cli.args.output, version_h_lines, cli.args.quiet)
diff --git a/lib/python/qmk/cli/git/__init__.py b/lib/python/qmk/cli/git/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/git/__init__.py
diff --git a/lib/python/qmk/cli/git/submodule.py b/lib/python/qmk/cli/git/submodule.py
new file mode 100644
index 0000000000..1cbfd74e88
--- /dev/null
+++ b/lib/python/qmk/cli/git/submodule.py
@@ -0,0 +1,53 @@
1import shutil
2from pathlib import Path
3
4from milc import cli
5
6from qmk import submodules
7
8REMOVE_DIRS = [
9 'lib/ugfx',
10 'lib/chibios-contrib/ext/mcux-sdk',
11]
12
13IGNORE_DIRS = [
14 'lib/arm_atsam',
15 'lib/fnv',
16 'lib/lib8tion',
17 'lib/python',
18 'lib/usbhost',
19]
20
21
22@cli.argument('--check', arg_only=True, action='store_true', help='Check if the submodules are dirty, and display a warning if they are.')
23@cli.argument('--sync', arg_only=True, action='store_true', help='Shallow clone any missing submodules.')
24@cli.argument('-f', '--force', action='store_true', help='Flag to remove unexpected directories')
25@cli.subcommand('Git Submodule actions.')
26def git_submodule(cli):
27 """Git Submodule actions
28 """
29 if cli.args.check:
30 return all(item['status'] for item in submodules.status().values())
31
32 if cli.args.sync:
33 cli.run(['git', 'submodule', 'sync', '--recursive'])
34 for name, item in submodules.status().items():
35 if item['status'] is None:
36 cli.run(['git', 'submodule', 'update', '--depth=50', '--init', name], capture_output=False)
37 return True
38
39 # can be the default behavior with: qmk config git_submodule.force=True
40 remove_dirs = REMOVE_DIRS
41 if cli.config.git_submodule.force:
42 # Also trash everything that isnt marked as "safe"
43 for path in Path('lib').iterdir():
44 if not any(ignore in path.as_posix() for ignore in IGNORE_DIRS):
45 remove_dirs.append(path)
46
47 for folder in map(Path, remove_dirs):
48 if folder.is_dir():
49 print(f"Removing '{folder}'")
50 shutil.rmtree(folder)
51
52 cli.run(['git', 'submodule', 'sync', '--recursive'], capture_output=False)
53 cli.run(['git', 'submodule', 'update', '--init', '--recursive', '--progress'], capture_output=False)
diff --git a/lib/python/qmk/cli/hello.py b/lib/python/qmk/cli/hello.py
new file mode 100755
index 0000000000..5119188a07
--- /dev/null
+++ b/lib/python/qmk/cli/hello.py
@@ -0,0 +1,13 @@
1"""QMK Python Hello World
2
3This is an example QMK CLI script.
4"""
5from milc import cli
6
7
8@cli.argument('-n', '--name', default='World', help='Name to greet.')
9@cli.subcommand('QMK Hello World.', hidden=False if cli.config.user.developer else True)
10def hello(cli):
11 """Log a friendly greeting.
12 """
13 cli.log.info('Hello, %s!', cli.config.hello.name)
diff --git a/lib/python/qmk/cli/import/__init__.py b/lib/python/qmk/cli/import/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/import/__init__.py
diff --git a/lib/python/qmk/cli/import/kbfirmware.py b/lib/python/qmk/cli/import/kbfirmware.py
new file mode 100644
index 0000000000..feccb3cfcc
--- /dev/null
+++ b/lib/python/qmk/cli/import/kbfirmware.py
@@ -0,0 +1,25 @@
1from milc import cli
2
3from qmk.importers import import_kbfirmware as _import_kbfirmware
4from qmk.path import FileType
5from qmk.json_schema import json_load
6
7
8@cli.argument('filename', type=FileType('r'), nargs='+', arg_only=True, help='file')
9@cli.subcommand('Import kbfirmware json export')
10def import_kbfirmware(cli):
11 filename = cli.args.filename[0]
12
13 data = json_load(filename)
14
15 cli.log.info(f'{{style_bright}}Importing {filename.name}.{{style_normal}}')
16 cli.echo('')
17
18 cli.log.warning("Support here is basic - Consider using 'qmk new-keyboard' instead")
19
20 kb_name = _import_kbfirmware(data)
21
22 cli.log.info(f'{{fg_green}}Imported a new keyboard named {{fg_cyan}}{kb_name}{{fg_green}}.{{fg_reset}}')
23 cli.log.info(f'To start working on things, `cd` into {{fg_cyan}}keyboards/{kb_name}{{fg_reset}},')
24 cli.log.info('or open the directory in your preferred text editor.')
25 cli.log.info(f"And build with {{fg_yellow}}qmk compile -kb {kb_name} -km default{{fg_reset}}.")
diff --git a/lib/python/qmk/cli/import/keyboard.py b/lib/python/qmk/cli/import/keyboard.py
new file mode 100644
index 0000000000..3a5ed37dee
--- /dev/null
+++ b/lib/python/qmk/cli/import/keyboard.py
@@ -0,0 +1,23 @@
1from milc import cli
2
3from qmk.importers import import_keyboard as _import_keyboard
4from qmk.path import FileType
5from qmk.json_schema import json_load
6
7
8@cli.argument('filename', type=FileType('r'), nargs='+', arg_only=True, help='file')
9@cli.subcommand('Import data-driven keyboard')
10def import_keyboard(cli):
11 filename = cli.args.filename[0]
12
13 data = json_load(filename)
14
15 cli.log.info(f'{{style_bright}}Importing {filename.name}.{{style_normal}}')
16 cli.echo('')
17
18 kb_name = _import_keyboard(data)
19
20 cli.log.info(f'{{fg_green}}Imported a new keyboard named {{fg_cyan}}{kb_name}{{fg_green}}.{{fg_reset}}')
21 cli.log.info(f'To start working on things, `cd` into {{fg_cyan}}keyboards/{kb_name}{{fg_reset}},')
22 cli.log.info('or open the directory in your preferred text editor.')
23 cli.log.info(f"And build with {{fg_yellow}}qmk compile -kb {kb_name} -km default{{fg_reset}}.")
diff --git a/lib/python/qmk/cli/import/keymap.py b/lib/python/qmk/cli/import/keymap.py
new file mode 100644
index 0000000000..a499c93480
--- /dev/null
+++ b/lib/python/qmk/cli/import/keymap.py
@@ -0,0 +1,23 @@
1from milc import cli
2
3from qmk.importers import import_keymap as _import_keymap
4from qmk.path import FileType
5from qmk.json_schema import json_load
6
7
8@cli.argument('filename', type=FileType('r'), nargs='+', arg_only=True, help='file')
9@cli.subcommand('Import data-driven keymap')
10def import_keymap(cli):
11 filename = cli.args.filename[0]
12
13 data = json_load(filename)
14
15 cli.log.info(f'{{style_bright}}Importing {filename.name}.{{style_normal}}')
16 cli.echo('')
17
18 kb_name, km_name = _import_keymap(data)
19
20 cli.log.info(f'{{fg_green}}Imported a new keymap named {{fg_cyan}}{km_name}{{fg_green}}.{{fg_reset}}')
21 cli.log.info(f'To start working on things, `cd` into {{fg_cyan}}keyboards/{kb_name}/keymaps/{km_name}{{fg_reset}},')
22 cli.log.info('or open the directory in your preferred text editor.')
23 cli.log.info(f"And build with {{fg_yellow}}qmk compile -kb {kb_name} -km {km_name}{{fg_reset}}.")
diff --git a/lib/python/qmk/cli/info.py b/lib/python/qmk/cli/info.py
new file mode 100755
index 0000000000..26f7f0269d
--- /dev/null
+++ b/lib/python/qmk/cli/info.py
@@ -0,0 +1,277 @@
1"""Keyboard information script.
2
3Compile an info.json for a particular keyboard and pretty-print it.
4"""
5import sys
6import json
7
8from milc import cli
9
10from qmk.json_encoders import InfoJSONEncoder
11from qmk.constants import COL_LETTERS, ROW_LETTERS
12from qmk.decorators import automagic_keyboard, automagic_keymap
13from qmk.keyboard import keyboard_completer, keyboard_folder, render_layouts, render_layout, rules_mk
14from qmk.info import info_json, keymap_json
15from qmk.keymap import locate_keymap
16from qmk.path import is_keyboard
17
18UNICODE_SUPPORT = sys.stdout.encoding.lower().startswith('utf')
19
20
21def _strip_api_content(info_json):
22 # Ideally this would only be added in the API pathway.
23 info_json.pop('platform', None)
24 info_json.pop('platform_key', None)
25 info_json.pop('processor_type', None)
26 info_json.pop('protocol', None)
27 info_json.pop('config_h_features', None)
28 info_json.pop('keymaps', None)
29 info_json.pop('keyboard_folder', None)
30 info_json.pop('parse_errors', None)
31 info_json.pop('parse_warnings', None)
32
33 for layout in info_json.get('layouts', {}).values():
34 layout.pop('filename', None)
35 layout.pop('c_macro', None)
36 layout.pop('json_layout', None)
37
38 if 'matrix_pins' in info_json:
39 info_json.pop('matrix_size', None)
40
41 for feature in ['rgb_matrix', 'led_matrix']:
42 if info_json.get(feature, {}).get("layout", None):
43 info_json[feature].pop('led_count', None)
44
45 return info_json
46
47
48def show_keymap(kb_info_json, title_caps=True):
49 """Render the keymap in ascii art.
50 """
51 keymap_path = locate_keymap(cli.config.info.keyboard, cli.config.info.keymap)
52
53 if keymap_path and keymap_path.suffix == '.json':
54 keymap_data = json.load(keymap_path.open(encoding='utf-8'))
55
56 # cater for layout-less keymap.json
57 if 'layout' not in keymap_data:
58 return
59
60 layout_name = keymap_data['layout']
61 layout_name = kb_info_json.get('layout_aliases', {}).get(layout_name, layout_name) # Resolve alias names
62
63 for layer_num, layer in enumerate(keymap_data['layers']):
64 if title_caps:
65 cli.echo('{fg_cyan}Keymap %s Layer %s{fg_reset}:', cli.config.info.keymap, layer_num)
66 else:
67 cli.echo('{fg_cyan}keymap.%s.layer.%s{fg_reset}:', cli.config.info.keymap, layer_num)
68
69 print(render_layout(kb_info_json['layouts'][layout_name]['layout'], cli.config.info.ascii, layer))
70
71
72def show_layouts(kb_info_json, title_caps=True):
73 """Render the layouts with info.json labels.
74 """
75 for layout_name, layout_art in render_layouts(kb_info_json, cli.config.info.ascii).items():
76 title = f'Layout {layout_name.title()}' if title_caps else f'layouts.{layout_name}'
77 cli.echo('{fg_cyan}%s{fg_reset}:', title)
78 print(layout_art) # Avoid passing dirty data to cli.echo()
79
80
81def show_matrix(kb_info_json, title_caps=True):
82 """Render the layout with matrix labels in ascii art.
83 """
84 for layout_name, layout in kb_info_json['layouts'].items():
85 # Build our label list
86 labels = []
87 for key in layout['layout']:
88 if 'matrix' in key:
89 row = ROW_LETTERS[key['matrix'][0]]
90 col = COL_LETTERS[key['matrix'][1]]
91
92 labels.append(row + col)
93 else:
94 labels.append('')
95
96 # Print the header
97 if title_caps:
98 cli.echo('{fg_blue}Matrix for "%s"{fg_reset}:', layout_name)
99 else:
100 cli.echo('{fg_blue}matrix_%s{fg_reset}:', layout_name)
101
102 print(render_layout(kb_info_json['layouts'][layout_name]['layout'], cli.config.info.ascii, labels))
103
104
105def show_leds(kb_info_json, title_caps=True):
106 """Render LED indices per key, using the keyboard's key layout geometry.
107
108 We build a map from (row, col) -> LED index using rgb_matrix/led_matrix layout,
109 then label each key with its LED index. Keys without an associated LED are left blank.
110 """
111 # Prefer rgb_matrix, fall back to led_matrix
112 led_feature = None
113 for feature in ['rgb_matrix', 'led_matrix']:
114 if 'layout' in kb_info_json.get(feature, {}):
115 led_feature = feature
116 break
117
118 if not led_feature:
119 cli.echo('{fg_yellow}No rgb_matrix/led_matrix layout found to derive LED indices.{fg_reset}')
120 return
121
122 # Build mapping from matrix position -> LED indices for faster lookup later
123 by_matrix = {}
124 for idx, led in enumerate(kb_info_json[led_feature]['layout']):
125 if 'matrix' in led:
126 led_key = tuple(led.get('matrix'))
127 by_matrix[led_key] = idx
128
129 # For each keyboard layout (e.g., LAYOUT), render keys labeled with LED index (or blank)
130 for layout_name, layout in kb_info_json['layouts'].items():
131 labels = []
132 for key in layout['layout']:
133 led_key = tuple(key.get('matrix'))
134 label = str(by_matrix[led_key]) if led_key in by_matrix else ''
135
136 labels.append(label)
137
138 # Header
139 if title_caps:
140 cli.echo('{fg_blue}LED indices for "%s"{fg_reset}:', layout_name)
141 else:
142 cli.echo('{fg_blue}leds_%s{fg_reset}:', layout_name)
143
144 print(render_layout(kb_info_json['layouts'][layout_name]['layout'], cli.config.info.ascii, labels))
145
146
147def print_friendly_output(kb_info_json):
148 """Print the info.json in a friendly text format.
149 """
150 cli.echo('{fg_blue}Keyboard Name{fg_reset}: %s', kb_info_json.get('keyboard_name', 'Unknown'))
151 cli.echo('{fg_blue}Manufacturer{fg_reset}: %s', kb_info_json.get('manufacturer', 'Unknown'))
152 if 'url' in kb_info_json:
153 cli.echo('{fg_blue}Website{fg_reset}: %s', kb_info_json.get('url', ''))
154 if kb_info_json.get('maintainer', 'qmk') == 'qmk':
155 cli.echo('{fg_blue}Maintainer{fg_reset}: QMK Community')
156 else:
157 cli.echo('{fg_blue}Maintainer{fg_reset}: %s', kb_info_json['maintainer'])
158 cli.echo('{fg_blue}Layouts{fg_reset}: %s', ', '.join(sorted(kb_info_json['layouts'].keys())))
159 cli.echo('{fg_blue}Processor{fg_reset}: %s', kb_info_json.get('processor', 'Unknown'))
160 cli.echo('{fg_blue}Bootloader{fg_reset}: %s', kb_info_json.get('bootloader', 'Unknown'))
161 if 'layout_aliases' in kb_info_json:
162 aliases = [f'{key}={value}' for key, value in kb_info_json['layout_aliases'].items()]
163 cli.echo('{fg_blue}Layout aliases:{fg_reset} %s' % (', '.join(aliases),))
164
165
166def print_text_output(kb_info_json):
167 """Print the info.json in a plain text format.
168 """
169 for key in sorted(kb_info_json):
170 if key == 'layouts':
171 cli.echo('{fg_blue}layouts{fg_reset}: %s', ', '.join(sorted(kb_info_json['layouts'].keys())))
172 else:
173 cli.echo('{fg_blue}%s{fg_reset}: %s', key, kb_info_json[key])
174
175 if cli.config.info.layouts:
176 show_layouts(kb_info_json, False)
177
178 if cli.config.info.matrix:
179 show_matrix(kb_info_json, False)
180
181 if cli.config_source.info.keymap and cli.config_source.info.keymap != 'config_file':
182 show_keymap(kb_info_json, False)
183
184
185def print_dotted_output(kb_info_json, prefix=''):
186 """Print the info.json in a plain text format with dot-joined keys.
187 """
188 for key in sorted(kb_info_json):
189 new_prefix = f'{prefix}.{key}' if prefix else key
190
191 if key in ['parse_errors', 'parse_warnings']:
192 continue
193 elif key == 'layouts' and prefix == '':
194 cli.echo('{fg_blue}layouts{fg_reset}: %s', ', '.join(sorted(kb_info_json['layouts'].keys())))
195 elif isinstance(kb_info_json[key], dict):
196 print_dotted_output(kb_info_json[key], new_prefix)
197 elif isinstance(kb_info_json[key], list):
198 cli.echo('{fg_blue}%s{fg_reset}: %s', new_prefix, ', '.join(map(str, sorted(kb_info_json[key]))))
199 else:
200 cli.echo('{fg_blue}%s{fg_reset}: %s', new_prefix, kb_info_json[key])
201
202
203def print_parsed_rules_mk(keyboard_name):
204 rules = rules_mk(keyboard_name)
205 for k in sorted(rules.keys()):
206 print('%s = %s' % (k, rules[k]))
207 return
208
209
210@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='Keyboard to show info for.')
211@cli.argument('-km', '--keymap', help='Keymap to show info for (Optional).')
212@cli.argument('-l', '--layouts', action='store_true', help='Render the layouts.')
213@cli.argument('-m', '--matrix', action='store_true', help='Render the layouts with matrix information.')
214@cli.argument('-L', '--leds', action='store_true', help='Render the LED layout with LED indices (rgb_matrix/led_matrix).')
215@cli.argument('-f', '--format', default='friendly', arg_only=True, help='Format to display the data in (friendly, text, json) (Default: friendly).')
216@cli.argument('--ascii', action='store_true', default=not UNICODE_SUPPORT, help='Render layout box drawings in ASCII only.')
217@cli.argument('-r', '--rules-mk', action='store_true', help='Render the parsed values of the keyboard\'s rules.mk file.')
218@cli.argument('-a', '--api', action='store_true', help='Show fully processed info intended for API consumption.')
219@cli.subcommand('Keyboard information.')
220@automagic_keyboard
221@automagic_keymap
222def info(cli):
223 """Compile an info.json for a particular keyboard and pretty-print it.
224 """
225 # Determine our keyboard(s)
226 if not cli.config.info.keyboard:
227 cli.log.error('Missing parameter: --keyboard')
228 cli.subcommands['info'].print_help()
229 return False
230
231 if not is_keyboard(cli.config.info.keyboard):
232 cli.log.error('Invalid keyboard: "%s"', cli.config.info.keyboard)
233 return False
234
235 if bool(cli.args.rules_mk):
236 print_parsed_rules_mk(cli.config.info.keyboard)
237 return False
238
239 # default keymap stored in config file should be ignored
240 if cli.config_source.info.keymap == 'config_file':
241 cli.config_source.info.keymap = None
242
243 # Build the info.json file
244 if cli.config.info.keymap:
245 kb_info_json = keymap_json(cli.config.info.keyboard, cli.config.info.keymap)
246 else:
247 kb_info_json = info_json(cli.config.info.keyboard)
248
249 if not cli.args.api:
250 kb_info_json = _strip_api_content(kb_info_json)
251
252 # Output in the requested format
253 if cli.args.format == 'json':
254 print(json.dumps(kb_info_json, cls=InfoJSONEncoder, sort_keys=True))
255 return True
256 elif cli.args.format == 'text':
257 print_dotted_output(kb_info_json)
258 title_caps = False
259 elif cli.args.format == 'friendly':
260 print_friendly_output(kb_info_json)
261 title_caps = True
262 else:
263 cli.log.error('Unknown format: %s', cli.args.format)
264 return False
265
266 # Output requested extras
267 if cli.config.info.layouts:
268 show_layouts(kb_info_json, title_caps)
269
270 if cli.config.info.matrix:
271 show_matrix(kb_info_json, title_caps)
272
273 if cli.config.info.leds:
274 show_leds(kb_info_json, title_caps)
275
276 if cli.config.info.keymap:
277 show_keymap(kb_info_json, title_caps)
diff --git a/lib/python/qmk/cli/json2c.py b/lib/python/qmk/cli/json2c.py
new file mode 100755
index 0000000000..a2db314947
--- /dev/null
+++ b/lib/python/qmk/cli/json2c.py
@@ -0,0 +1,28 @@
1"""Generate a keymap.c from a configurator export.
2"""
3from argcomplete.completers import FilesCompleter
4from milc import cli
5
6import qmk.keymap
7import qmk.path
8from qmk.commands import dump_lines, parse_configurator_json
9
10
11@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
12@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
13@cli.argument('filename', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
14@cli.subcommand('Creates a keymap.c from a QMK Configurator export.')
15def json2c(cli):
16 """Generate a keymap.c from a configurator export.
17
18 This command uses the `qmk.keymap` module to generate a keymap.c from a configurator export. The generated keymap is written to stdout, or to a file if -o is provided.
19 """
20
21 # Parse the configurator from json file (or stdin)
22 user_keymap = parse_configurator_json(cli.args.filename)
23
24 # Generate the keymap
25 keymap_c = qmk.keymap.generate_c(user_keymap)
26
27 # Show the results
28 dump_lines(cli.args.output, keymap_c.split('\n'), cli.args.quiet)
diff --git a/lib/python/qmk/cli/kle2json.py b/lib/python/qmk/cli/kle2json.py
new file mode 100755
index 0000000000..bbfddf4268
--- /dev/null
+++ b/lib/python/qmk/cli/kle2json.py
@@ -0,0 +1,59 @@
1"""Convert raw KLE to JSON
2"""
3import json
4import os
5from pathlib import Path
6
7from argcomplete.completers import FilesCompleter
8from milc import cli
9from kle2xy import KLE2xy
10
11from qmk.converter import kle2qmk
12from qmk.json_encoders import InfoJSONEncoder
13
14
15@cli.argument('filename', completer=FilesCompleter('.json'), help='The KLE raw txt to convert')
16@cli.argument('-f', '--force', action='store_true', help='Flag to overwrite current info.json')
17@cli.subcommand('Convert a KLE layout to a Configurator JSON', hidden=False if cli.config.user.developer else True)
18def kle2json(cli):
19 """Convert a KLE layout to QMK's layout format.
20 """ # If filename is a path
21 if cli.args.filename.startswith("/") or cli.args.filename.startswith("./"):
22 file_path = Path(cli.args.filename)
23 # Otherwise assume it is a file name
24 else:
25 file_path = Path(os.environ['ORIG_CWD'], cli.args.filename)
26 # Check for valid file_path for more graceful failure
27 if not file_path.exists():
28 cli.log.error('File {fg_cyan}%s{style_reset_all} was not found.', file_path)
29 return False
30 out_path = file_path.parent
31 raw_code = file_path.read_text(encoding='utf-8')
32 # Check if info.json exists, allow overwrite with force
33 if Path(out_path, "info.json").exists() and not cli.args.force:
34 cli.log.error('File {fg_cyan}%s/info.json{style_reset_all} already exists, use -f or --force to overwrite.', out_path)
35 return False
36 try:
37 # Convert KLE raw to x/y coordinates (using kle2xy package from skullydazed)
38 kle = KLE2xy(raw_code)
39 except Exception as e:
40 cli.log.error('Could not parse KLE raw data: %s', raw_code)
41 cli.log.exception(e)
42 return False
43 keyboard = {
44 'keyboard_name': kle.name,
45 'url': '',
46 'maintainer': 'qmk',
47 'layouts': {
48 'LAYOUT': {
49 'layout': kle2qmk(kle)
50 }
51 },
52 }
53
54 # Write our info.json
55 keyboard = json.dumps(keyboard, indent=4, separators=(', ', ': '), sort_keys=False, cls=InfoJSONEncoder)
56 info_json_file = out_path / 'info.json'
57
58 info_json_file.write_text(keyboard)
59 cli.log.info('Wrote out {fg_cyan}%s/info.json', out_path)
diff --git a/lib/python/qmk/cli/license_check.py b/lib/python/qmk/cli/license_check.py
new file mode 100644
index 0000000000..119a228c6d
--- /dev/null
+++ b/lib/python/qmk/cli/license_check.py
@@ -0,0 +1,131 @@
1# Copyright 2023 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3import re
4from milc import cli
5from qmk.constants import LICENSE_TEXTS
6from qmk.path import normpath
7
8L_PAREN = re.compile(r'\(\[\{\<')
9R_PAREN = re.compile(r'\)\]\}\>')
10PUNCTUATION = re.compile(r'[\.,;:]+')
11TRASH_PREFIX = re.compile(r'^(\s|/|\*|#)+')
12TRASH_SUFFIX = re.compile(r'(\s|/|\*|#|\\)+$')
13SPACE = re.compile(r'\s+')
14SUFFIXES = ['.c', '.h', '.cpp', '.cxx', '.hpp', '.hxx']
15
16
17def _simplify_text(input):
18 lines = input.lower().split('\n')
19 lines = [PUNCTUATION.sub('', line) for line in lines]
20 lines = [TRASH_PREFIX.sub('', line) for line in lines]
21 lines = [TRASH_SUFFIX.sub('', line) for line in lines]
22 lines = [SPACE.sub(' ', line) for line in lines]
23 lines = [L_PAREN.sub('(', line) for line in lines]
24 lines = [R_PAREN.sub(')', line) for line in lines]
25 lines = [line.strip() for line in lines]
26 lines = [line for line in lines if line is not None and line != '']
27 return ' '.join(lines)
28
29
30def _preformat_license_texts():
31 # Pre-format all the licenses
32 for _, long_licenses in LICENSE_TEXTS:
33 for i in range(len(long_licenses)):
34 long_licenses[i] = _simplify_text(long_licenses[i])
35
36
37def _determine_suffix_condition(extensions):
38 def _default_suffix_condition(s):
39 return s in SUFFIXES
40
41 conditional = _default_suffix_condition
42
43 if extensions is not None and len(extensions) > 0:
44 suffixes = [f'.{s}' if not s.startswith('.') else s for s in extensions]
45
46 def _specific_suffix_condition(s):
47 return s in suffixes
48
49 conditional = _specific_suffix_condition
50
51 return conditional
52
53
54def _determine_file_list(inputs, conditional):
55 check_list = set()
56 for filename in inputs:
57 if filename.is_dir():
58 for file in sorted(filename.rglob('*')):
59 if file.is_file() and conditional(file.suffix):
60 check_list.add(file)
61 elif filename.is_file():
62 if conditional(filename.suffix):
63 check_list.add(filename)
64
65 return list(sorted(check_list))
66
67
68def _detect_license_from_file_contents(filename, absolute=False, short=False):
69 data = filename.read_text(encoding='utf-8', errors='ignore')
70 filename_out = str(filename.absolute()) if absolute else str(filename)
71
72 if 'SPDX-License-Identifier:' in data:
73 res = data.split('SPDX-License-Identifier:')
74 license = re.split(r'\s|//|\*', res[1].strip())[0].strip()
75 found = False
76 for short_license, _ in LICENSE_TEXTS:
77 if license.lower() == short_license.lower():
78 license = short_license
79 found = True
80 break
81
82 if not found:
83 if short:
84 print(f'{filename_out} UNKNOWN')
85 else:
86 cli.log.error(f'{{fg_cyan}}{filename_out}{{fg_reset}} -- unknown license, or no license detected!')
87 return False
88
89 if short:
90 print(f'{filename_out} {license}')
91 else:
92 cli.log.info(f'{{fg_cyan}}{filename_out}{{fg_reset}} -- license detected: {license} (SPDX License Identifier)')
93 return True
94
95 else:
96 simple_text = _simplify_text(data)
97 for short_license, long_licenses in LICENSE_TEXTS:
98 for long_license in long_licenses:
99 if long_license in simple_text:
100 if short:
101 print(f'{filename_out} {short_license}')
102 else:
103 cli.log.info(f'{{fg_cyan}}{filename_out}{{fg_reset}} -- license detected: {short_license} (Full text)')
104 return True
105
106 if short:
107 print(f'{filename_out} UNKNOWN')
108 else:
109 cli.log.error(f'{{fg_cyan}}{filename_out}{{fg_reset}} -- unknown license, or no license detected!')
110
111 return False
112
113
114@cli.argument('inputs', nargs='*', arg_only=True, type=normpath, help='List of input files or directories.')
115@cli.argument('-s', '--short', action='store_true', help='Short output.')
116@cli.argument('-a', '--absolute', action='store_true', help='Print absolute paths.')
117@cli.argument('-e', '--extension', arg_only=True, action='append', default=[], help='Override list of extensions. Can be specified multiple times for multiple extensions.')
118@cli.subcommand('File license check.', hidden=False if cli.config.user.developer else True)
119def license_check(cli):
120 _preformat_license_texts()
121
122 conditional = _determine_suffix_condition(cli.args.extension)
123 check_list = _determine_file_list(cli.args.inputs, conditional)
124
125 failed = False
126 for filename in sorted(check_list):
127 if not _detect_license_from_file_contents(filename, absolute=cli.args.absolute, short=cli.args.short):
128 failed = True
129
130 if failed:
131 return False
diff --git a/lib/python/qmk/cli/lint.py b/lib/python/qmk/cli/lint.py
new file mode 100644
index 0000000000..8a128ce6d2
--- /dev/null
+++ b/lib/python/qmk/cli/lint.py
@@ -0,0 +1,406 @@
1"""Command to look over a keyboard/keymap and check for common mistakes.
2"""
3from dotty_dict import dotty
4from pathlib import Path
5
6from milc import cli
7
8from qmk.decorators import automagic_keyboard, automagic_keymap
9from qmk.info import info_json
10from qmk.keyboard import keyboard_completer, keyboard_folder_or_all, is_all_keyboards, list_keyboards
11from qmk.keymap import locate_keymap, list_keymaps
12from qmk.path import keyboard
13from qmk.git import git_get_ignored_files
14from qmk.c_parse import c_source_files, preprocess_c_file
15from qmk.json_schema import json_load
16
17CHIBIOS_CONF_CHECKS = ['chconf.h', 'halconf.h', 'mcuconf.h', 'board.h']
18INVALID_KB_FEATURES = set(['encoder_map', 'dip_switch_map', 'combo', 'tap_dance', 'via'])
19INVALID_KM_NAMES = ['via', 'vial']
20
21
22def _list_defaultish_keymaps(kb):
23 """Return default like keymaps for a given keyboard
24 """
25 defaultish = ['ansi', 'iso']
26
27 # This is only here to flag it as "testable", so it doesn't fly under the radar during PR
28 defaultish.extend(INVALID_KM_NAMES)
29
30 keymaps = set()
31 for x in list_keymaps(kb, include_userspace=False):
32 if x in defaultish or x.startswith('default'):
33 keymaps.add(x)
34
35 return keymaps
36
37
38def _get_readme_files(kb, km=None):
39 """Return potential keyboard/keymap readme files
40 """
41 search_path = locate_keymap(kb, km).parent if km else keyboard(kb)
42
43 readme_files = []
44
45 if not km:
46 current_path = Path(search_path.parts[0])
47 for path_part in search_path.parts[1:]:
48 current_path = current_path / path_part
49 readme_files.extend(current_path.glob('*readme.md'))
50
51 for file in search_path.glob("**/*readme.md"):
52 # Ignore keymaps when only globing keyboard files
53 if not km and 'keymaps' in file.parts:
54 continue
55 readme_files.append(file)
56
57 return set(readme_files)
58
59
60def _get_build_files(kb, km=None):
61 """Return potential keyboard/keymap build files
62 """
63 search_path = locate_keymap(kb, km).parent if km else keyboard(kb)
64
65 build_files = []
66
67 if not km:
68 current_path = Path()
69 for path_part in search_path.parts:
70 current_path = current_path / path_part
71 build_files.extend(current_path.glob('*rules.mk'))
72
73 for file in search_path.glob("**/*rules.mk"):
74 # Ignore keymaps when only globing keyboard files
75 if not km and 'keymaps' in file.parts:
76 continue
77 build_files.append(file)
78
79 return set(build_files)
80
81
82def _get_code_files(kb, km=None):
83 """Return potential keyboard/keymap code files
84 """
85 search_path = locate_keymap(kb, km).parent if km else keyboard(kb)
86
87 code_files = []
88
89 if not km:
90 current_path = Path()
91 for path_part in search_path.parts:
92 current_path = current_path / path_part
93 code_files.extend(current_path.glob('*.h'))
94 code_files.extend(current_path.glob('*.c'))
95
96 for file in c_source_files([search_path]):
97 # Ignore keymaps when only globing keyboard files
98 if not km and 'keymaps' in file.parts:
99 continue
100 code_files.append(file)
101
102 return code_files
103
104
105def _is_invalid_readme(file):
106 """Check if file contains any unfilled content
107 """
108 tokens = [
109 '%KEYBOARD%',
110 '%REAL_NAME%',
111 '%USER_NAME%',
112 'image replace me!',
113 'A short description of the keyboard/project',
114 'The PCBs, controllers supported',
115 'Links to where you can find this hardware',
116 ]
117
118 for line in file.read_text(encoding='utf-8').split("\n"):
119 if any(token in line for token in tokens):
120 return True
121 return False
122
123
124def _is_empty_rules(file):
125 """Check if file contains any useful content
126 """
127 for line in file.read_text(encoding='utf-8').split("\n"):
128 if len(line) > 0 and not line.isspace() and not line.startswith('#'):
129 return False
130 return True
131
132
133def _is_empty_include(file):
134 """Check if file contains any useful content
135 """
136 for line in preprocess_c_file(file).split("\n"):
137 if len(line) > 0 and not line.isspace() and not line.startswith('#pragma once'):
138 return False
139 return True
140
141
142def _has_license(file):
143 """Check file has a license header
144 """
145 # Crude assumption that first line of license header is a comment
146 fline = open(file).readline().rstrip()
147 return fline.startswith(("/*", "//"))
148
149
150def _handle_json_errors(kb, info):
151 """Convert any json errors into lint errors
152 """
153 ok = True
154 # Check for errors in the json
155 if info['parse_errors']:
156 ok = False
157 cli.log.error(f'{kb}: Errors found when generating info.json.')
158
159 if cli.config.lint.strict and info['parse_warnings']:
160 ok = False
161 cli.log.error(f'{kb}: Warnings found when generating info.json (Strict mode enabled.)')
162 return ok
163
164
165def _handle_invalid_features(kb, info):
166 """Check for features that should never be enabled at the keyboard level
167 """
168 ok = True
169 features = set(info.get('features', []))
170 for found in features & INVALID_KB_FEATURES:
171 ok = False
172 cli.log.error(f'{kb}: Invalid keyboard level feature detected - {found}')
173 return ok
174
175
176def _handle_invalid_config(kb, info):
177 """Check for invalid keyboard level config
178 """
179 if info.get('url') == "":
180 cli.log.warning(f'{kb}: Invalid keyboard level config detected - Optional field "url" should not be empty.')
181 return True
182
183
184def _chibios_conf_includenext_check(target):
185 """Check the ChibiOS conf.h for the correct inclusion of the next conf.h
186 """
187 for i, line in enumerate(target.open()):
188 if f'#include_next "{target.name}"' in line:
189 return f'Found `#include_next "{target.name}"` on line {i} of {target}, should be `#include_next <{target.name}>` (use angle brackets, not quotes)'
190 return None
191
192
193def _rules_mk_assignment_only(rules_mk):
194 """Check the keyboard-level rules.mk to ensure it only has assignments.
195 """
196 errors = []
197 continuation = None
198 for i, line in enumerate(rules_mk.open()):
199 line = line.strip()
200
201 if '#' in line:
202 line = line[:line.index('#')]
203
204 if continuation:
205 line = continuation + line
206 continuation = None
207
208 if line:
209 if line[-1] == '\\':
210 continuation = line[:-1]
211 continue
212
213 if line and '=' not in line:
214 errors.append(f'Non-assignment code on line +{i} {rules_mk}: {line}')
215
216 return errors
217
218
219def _handle_duplicating_code_defaults(kb, info):
220 def _collect_dotted_output(kb_info_json, prefix=''):
221 """Print the info.json in a plain text format with dot-joined keys.
222 """
223 for key in sorted(kb_info_json):
224 new_prefix = f'{prefix}.{key}' if prefix else key
225
226 if isinstance(kb_info_json[key], dict):
227 yield from _collect_dotted_output(kb_info_json[key], new_prefix)
228 elif isinstance(kb_info_json[key], list):
229 # TODO: handle non primitives?
230 yield (new_prefix, kb_info_json[key])
231 else:
232 yield (new_prefix, kb_info_json[key])
233
234 defaults_map = json_load(Path('data/mappings/info_defaults.hjson'))
235 dotty_info = dotty(info)
236
237 for key, v_default in _collect_dotted_output(defaults_map):
238 v_info = dotty_info.get(key)
239 if v_default == v_info:
240 cli.log.warning(f'{kb}: Option "{key}" duplicates default value of "{v_default}"')
241
242 return True
243
244
245def keymap_check(kb, km):
246 """Perform the keymap level checks.
247 """
248 ok = True
249 keymap_path = locate_keymap(kb, km)
250
251 if not keymap_path:
252 ok = False
253 cli.log.error("%s: Can't find %s keymap.", kb, km)
254 return ok
255
256 if km in INVALID_KM_NAMES:
257 ok = False
258 cli.log.error("%s: The keymap %s should not exist!", kb, km)
259 return ok
260
261 # Additional checks
262 invalid_files = git_get_ignored_files(keymap_path.parent.as_posix())
263 for file in invalid_files:
264 cli.log.error(f'{kb}/{km}: The file "{file}" should not exist!')
265 ok = False
266
267 for file in _get_code_files(kb, km):
268 if not _has_license(file):
269 cli.log.error(f'{kb}/{km}: The file "{file}" does not have a license header!')
270 ok = False
271
272 if file.name in CHIBIOS_CONF_CHECKS:
273 check_error = _chibios_conf_includenext_check(file)
274 if check_error is not None:
275 cli.log.error(f'{kb}/{km}: {check_error}')
276 ok = False
277
278 return ok
279
280
281def keyboard_check(kb): # noqa C901
282 """Perform the keyboard level checks.
283 """
284 ok = True
285 kb_info = info_json(kb)
286
287 if not _handle_json_errors(kb, kb_info):
288 ok = False
289
290 # Additional checks
291 if not _handle_invalid_features(kb, kb_info):
292 ok = False
293
294 if not _handle_invalid_config(kb, kb_info):
295 ok = False
296
297 if not _handle_duplicating_code_defaults(kb, kb_info):
298 ok = False
299
300 invalid_files = git_get_ignored_files(f'keyboards/{kb}/')
301 for file in invalid_files:
302 if 'keymap' in file:
303 continue
304 cli.log.error(f'{kb}: The file "{file}" should not exist!')
305 ok = False
306
307 if not _get_readme_files(kb):
308 cli.log.error(f'{kb}: Is missing a readme.md file!')
309 ok = False
310
311 for file in _get_readme_files(kb):
312 if _is_invalid_readme(file):
313 cli.log.error(f'{kb}: The file "{file}" still contains template tokens!')
314 ok = False
315
316 for file in _get_build_files(kb):
317 if _is_empty_rules(file):
318 cli.log.error(f'{kb}: The file "{file}" is effectively empty and should be removed!')
319 ok = False
320
321 if file.suffix in ['rules.mk']:
322 rules_mk_assignment_errors = _rules_mk_assignment_only(file)
323 if rules_mk_assignment_errors:
324 ok = False
325 cli.log.error('%s: Non-assignment code found in rules.mk. Move it to post_rules.mk instead.', kb)
326 for assignment_error in rules_mk_assignment_errors:
327 cli.log.error(assignment_error)
328
329 for file in _get_code_files(kb):
330 if not _has_license(file):
331 cli.log.error(f'{kb}: The file "{file}" does not have a license header!')
332 ok = False
333
334 if file.name in ['config.h']:
335 if _is_empty_include(file):
336 cli.log.error(f'{kb}: The file "{file}" is effectively empty and should be removed!')
337 ok = False
338
339 if file.name in CHIBIOS_CONF_CHECKS:
340 check_error = _chibios_conf_includenext_check(file)
341 if check_error is not None:
342 cli.log.error(f'{kb}: {check_error}')
343 ok = False
344
345 return ok
346
347
348@cli.argument('--strict', action='store_true', help='Treat warnings as errors')
349@cli.argument('-kb', '--keyboard', action='append', type=keyboard_folder_or_all, completer=keyboard_completer, help='Keyboard to check. May be passed multiple times.')
350@cli.argument('-km', '--keymap', help='The keymap to check')
351@cli.subcommand('Check keyboard and keymap for common mistakes.')
352@automagic_keyboard
353@automagic_keymap
354def lint(cli):
355 """Check keyboard and keymap for common mistakes.
356 """
357 # Determine our keyboard list
358 if not cli.config.lint.keyboard:
359 cli.log.error('Missing required arguments: --keyboard')
360 cli.print_help()
361 return False
362
363 if isinstance(cli.config.lint.keyboard, str):
364 # if provided via config - string not array
365 keyboard_list = [cli.config.lint.keyboard]
366 elif any(is_all_keyboards(kb) for kb in cli.args.keyboard):
367 keyboard_list = list_keyboards()
368 else:
369 keyboard_list = list(set(cli.config.lint.keyboard))
370
371 failed = []
372
373 # Lint each keyboard
374 for kb in keyboard_list:
375 # Determine keymaps to also check
376 if cli.args.keymap == 'all':
377 keymaps = list_keymaps(kb)
378 elif cli.config.lint.keymap:
379 keymaps = {cli.config.lint.keymap}
380 else:
381 keymaps = _list_defaultish_keymaps(kb)
382 # Ensure that at least a 'default' keymap always exists
383 keymaps.add('default')
384
385 ok = True
386
387 # keyboard level checks
388 if not keyboard_check(kb):
389 ok = False
390
391 # Keymap specific checks
392 for keymap in keymaps:
393 if not keymap_check(kb, keymap):
394 ok = False
395
396 # Report status
397 if not ok:
398 failed.append(kb)
399
400 # Check and report the overall status
401 if failed:
402 cli.log.error('Lint check failed for: %s', ', '.join(failed))
403 return False
404
405 cli.log.info('Lint check passed!')
406 return True
diff --git a/lib/python/qmk/cli/list/__init__.py b/lib/python/qmk/cli/list/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/list/__init__.py
diff --git a/lib/python/qmk/cli/list/keyboards.py b/lib/python/qmk/cli/list/keyboards.py
new file mode 100644
index 0000000000..8b6c451673
--- /dev/null
+++ b/lib/python/qmk/cli/list/keyboards.py
@@ -0,0 +1,13 @@
1"""List the keyboards currently defined within QMK
2"""
3from milc import cli
4
5import qmk.keyboard
6
7
8@cli.subcommand("List the keyboards currently defined within QMK")
9def list_keyboards(cli):
10 """List the keyboards currently defined within QMK
11 """
12 for keyboard_name in qmk.keyboard.list_keyboards():
13 print(keyboard_name)
diff --git a/lib/python/qmk/cli/list/keymaps.py b/lib/python/qmk/cli/list/keymaps.py
new file mode 100644
index 0000000000..d2ef136c06
--- /dev/null
+++ b/lib/python/qmk/cli/list/keymaps.py
@@ -0,0 +1,22 @@
1"""List the keymaps for a specific keyboard
2"""
3from milc import cli
4
5import qmk.keymap
6from qmk.decorators import automagic_keyboard
7from qmk.keyboard import keyboard_completer, keyboard_folder
8
9
10@cli.argument("-kb", "--keyboard", type=keyboard_folder, completer=keyboard_completer, help="Specify keyboard name. Example: 1upkeyboards/1up60hse")
11@cli.subcommand("List the keymaps for a specific keyboard")
12@automagic_keyboard
13def list_keymaps(cli):
14 """List the keymaps for a specific keyboard
15 """
16 if not cli.config.list_keymaps.keyboard:
17 cli.log.error('Missing required arguments: --keyboard')
18 cli.subcommands['list-keymaps'].print_help()
19 return False
20
21 for name in qmk.keymap.list_keymaps(cli.config.list_keymaps.keyboard):
22 print(name)
diff --git a/lib/python/qmk/cli/list/layouts.py b/lib/python/qmk/cli/list/layouts.py
new file mode 100644
index 0000000000..df593dc390
--- /dev/null
+++ b/lib/python/qmk/cli/list/layouts.py
@@ -0,0 +1,23 @@
1"""List the keymaps for a specific keyboard
2"""
3from milc import cli
4
5from qmk.decorators import automagic_keyboard
6from qmk.keyboard import keyboard_completer, keyboard_folder
7from qmk.info import info_json
8
9
10@cli.argument("-kb", "--keyboard", type=keyboard_folder, completer=keyboard_completer, help="Specify keyboard name. Example: monarch")
11@cli.subcommand("List the layouts for a specific keyboard")
12@automagic_keyboard
13def list_layouts(cli):
14 """List the layouts for a specific keyboard
15 """
16 if not cli.config.list_layouts.keyboard:
17 cli.log.error('Missing required arguments: --keyboard')
18 cli.subcommands['list-layouts'].print_help()
19 return False
20
21 info_data = info_json(cli.config.list_layouts.keyboard)
22 for name in sorted(info_data.get('community_layouts', [])):
23 print(name)
diff --git a/lib/python/qmk/cli/mass_compile.py b/lib/python/qmk/cli/mass_compile.py
new file mode 100755
index 0000000000..e71280f482
--- /dev/null
+++ b/lib/python/qmk/cli/mass_compile.py
@@ -0,0 +1,151 @@
1"""Compile all keyboards.
2
3This will compile everything in parallel, for testing purposes.
4"""
5import os
6from typing import List
7from pathlib import Path
8from subprocess import DEVNULL
9from milc import cli
10import shlex
11
12from qmk.constants import QMK_FIRMWARE
13from qmk.commands import find_make, get_make_parallel_args, build_environment
14from qmk.search import search_keymap_targets, search_make_targets
15from qmk.build_targets import BuildTarget, JsonKeymapBuildTarget
16from qmk.util import maybe_exit_config
17
18
19def mass_compile_targets(targets: List[BuildTarget], clean: bool, dry_run: bool, no_temp: bool, parallel: int, print_failures: bool, **env):
20 if len(targets) == 0:
21 return
22
23 os.environ.setdefault('SKIP_SCHEMA_VALIDATION', '1')
24
25 make_cmd = find_make()
26 builddir = Path(QMK_FIRMWARE) / '.build'
27 makefile = builddir / 'parallel_kb_builds.mk'
28
29 if dry_run:
30 cli.log.info('Compilation targets:')
31 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)):
32 extra_args = ' '.join([f"-e {shlex.quote(f'{k}={v}')}" for k, v in target.extra_args.items()])
33 cli.log.info(f"{{fg_cyan}}qmk compile -kb {target.keyboard} -km {target.keymap} {extra_args}{{fg_reset}}")
34 else:
35 if clean:
36 cli.run([make_cmd, 'clean'], capture_output=False, stdin=DEVNULL)
37
38 builddir.mkdir(parents=True, exist_ok=True)
39 with open(makefile, "w") as f:
40 # yapf: disable
41 f.write(
42 f"""\
43# This file is auto-generated by qmk mass-compile
44# Do not edit this file directly.
45all: print_failures
46.PHONY: all_targets print_failures
47print_failures: all_targets
48"""# noqa
49 )
50 if print_failures:
51 f.write(
52 f"""\
53 @for f in $$(ls .build/failed.log.{os.getpid()}.* 2>/dev/null | sort); do \\
54 echo; \\
55 echo "======================================================================================"; \\
56 echo "Failed build log: $$f"; \\
57 echo "------------------------------------------------------"; \\
58 cat $$f; \\
59 echo "------------------------------------------------------"; \\
60 done
61"""# noqa
62 )
63 # yapf: enable
64 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)):
65 keyboard_name = target.keyboard
66 keymap_name = target.keymap
67 keyboard_safe = keyboard_name.replace('/', '_')
68 target_filename = target.target_name(**env)
69 target.configure(parallel=1) # We ignore parallelism on a per-build basis as we defer to the parent make invocation
70 target.prepare_build(**env) # If we've got json targets, allow them to write out any extra info to .build before we kick off `make`
71 command = target.compile_command(**env)
72 command[0] = '+@$(MAKE)' # Override the make so that we can use jobserver to handle parallelism
73 extra_args = '_'.join([f"{k}_{v}" for k, v in target.extra_args.items()])
74 build_log = f"{QMK_FIRMWARE}/.build/build.log.{os.getpid()}.{keyboard_safe}.{keymap_name}"
75 failed_log = f"{QMK_FIRMWARE}/.build/failed.log.{os.getpid()}.{keyboard_safe}.{keymap_name}"
76 target_suffix = ''
77 if len(extra_args) > 0:
78 build_log += f".{extra_args}"
79 failed_log += f".{extra_args}"
80 target_suffix = f"_{extra_args}"
81 # yapf: disable
82 f.write(
83 f"""\
84.PHONY: {target_filename}{target_suffix}_binary
85all_targets: {target_filename}{target_suffix}_binary
86{target_filename}{target_suffix}_binary:
87 @rm -f "{build_log}" || true
88 @echo "Compiling QMK Firmware for target: '{keyboard_name}:{keymap_name}'..." >>"{build_log}"
89 {' '.join(command)} \\
90 >>"{build_log}" 2>&1 \\
91 || cp "{build_log}" "{failed_log}"
92 @{{ grep '\\[ERRORS\\]' "{build_log}" >/dev/null 2>&1 && printf "Build %-64s \\e[1;31m[ERRORS]\\e[0m\\n" "{keyboard_name}:{keymap_name}" ; }} \\
93 || {{ grep '\\[WARNINGS\\]' "{build_log}" >/dev/null 2>&1 && printf "Build %-64s \\e[1;33m[WARNINGS]\\e[0m\\n" "{keyboard_name}:{keymap_name}" ; }} \\
94 || printf "Build %-64s \\e[1;32m[OK]\\e[0m\\n" "{keyboard_name}:{keymap_name}"
95 @rm -f "{build_log}" || true
96"""# noqa
97 )
98 # yapf: enable
99
100 if no_temp:
101 # yapf: disable
102 f.write(
103 f"""\
104 @rm -rf "{QMK_FIRMWARE}/.build/{target_filename}.elf" 2>/dev/null || true
105 @rm -rf "{QMK_FIRMWARE}/.build/{target_filename}.map" 2>/dev/null || true
106 @rm -rf "{QMK_FIRMWARE}/.build/obj_{target_filename}" || true
107"""# noqa
108 )
109 # yapf: enable
110 f.write('\n')
111
112 cli.run([find_make(), *get_make_parallel_args(parallel), '-f', makefile.as_posix(), 'all'], capture_output=False, stdin=DEVNULL)
113
114 # Check for failures
115 failures = [f for f in builddir.glob(f'failed.log.{os.getpid()}.*')]
116 if len(failures) > 0:
117 return False
118
119
120@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap> to compile in parallel. Specifying this overrides all other target search options.")
121@cli.argument('-t', '--no-temp', arg_only=True, action='store_true', help="Remove temporary files during build.")
122@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
123@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
124@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually build, just show the commands to be run.")
125@cli.argument('-p', '--print-failures', arg_only=True, action='store_true', help="Print failed builds.")
126@cli.argument(
127 '-f',
128 '--filter',
129 arg_only=True,
130 action='append',
131 default=[],
132 help= # noqa: `format-python` and `pytest` don't agree here.
133 "Filter the list of keyboards based on the supplied value in rules.mk. Matches info.json structure, and accepts the formats 'features.rgblight=true' or 'exists(matrix_pins.direct)'. May be passed multiple times, all filters need to match. Value may include wildcards such as '*' and '?'." # noqa: `format-python` and `pytest` don't agree here.
134)
135@cli.argument('-km', '--keymap', type=str, default='default', help="The keymap name to build. Default is 'default'.")
136@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.")
137@cli.subcommand('Compile QMK Firmware for all keyboards.', hidden=False if cli.config.user.developer else True)
138def mass_compile(cli):
139 """Compile QMK Firmware against all keyboards.
140 """
141 maybe_exit_config(should_exit=False, should_reraise=True)
142
143 if len(cli.args.builds) > 0:
144 json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)])
145 make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds))
146 targets = search_make_targets(make_like_targets)
147 targets.extend([JsonKeymapBuildTarget(e) for e in json_like_targets])
148 else:
149 targets = search_keymap_targets([('all', cli.config.mass_compile.keymap)], cli.args.filter)
150
151 return mass_compile_targets(targets, cli.args.clean, cli.args.dry_run, cli.args.no_temp, cli.config.mass_compile.parallel, cli.args.print_failures, **build_environment(cli.args.env))
diff --git a/lib/python/qmk/cli/migrate.py b/lib/python/qmk/cli/migrate.py
new file mode 100644
index 0000000000..d0f195d737
--- /dev/null
+++ b/lib/python/qmk/cli/migrate.py
@@ -0,0 +1,84 @@
1"""Migrate keyboard configuration to "Data Driven"
2"""
3import json
4from pathlib import Path
5from dotty_dict import dotty
6
7from milc import cli
8
9from qmk.keyboard import keyboard_completer, keyboard_folder
10from qmk.info import info_json, find_info_json
11from qmk.json_encoders import InfoJSONEncoder
12from qmk.json_schema import json_load
13
14
15def _candidate_files(keyboard):
16 kb_dir = Path(keyboard)
17
18 cur_dir = Path('keyboards')
19 files = []
20 for dir in kb_dir.parts:
21 cur_dir = cur_dir / dir
22 files.append(cur_dir / 'config.h')
23 files.append(cur_dir / 'rules.mk')
24
25 return [file for file in files if file.exists()]
26
27
28@cli.argument('-f', '--filter', arg_only=True, action='append', default=[], help="Filter the performed migrations based on the supplied value. Supported format is 'KEY' located from 'data/mappings'. May be passed multiple times.")
29@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, required=True, help='The keyboard\'s name')
30@cli.subcommand('Migrate keyboard config to "Data Driven".', hidden=True)
31def migrate(cli):
32 """Migrate keyboard configuration to "Data Driven"
33 """
34 # Merge mappings as we do not care to where "KEY" is found just that its removed
35 info_config_map = json_load(Path('data/mappings/info_config.hjson'))
36 info_rules_map = json_load(Path('data/mappings/info_rules.hjson'))
37 info_map = {**info_config_map, **info_rules_map}
38
39 # Parse target info.json which will receive updates
40 target_info = Path(find_info_json(cli.args.keyboard)[0])
41 info_data = dotty(json_load(target_info))
42
43 # Already parsed used for updates
44 kb_info_json = dotty(info_json(cli.args.keyboard))
45
46 # List of candidate files
47 files = _candidate_files(cli.args.keyboard)
48
49 # Filter down keys if requested
50 keys = list(filter(lambda key: info_map[key].get("to_json", True), info_map.keys()))
51 if cli.args.filter:
52 keys = list(set(keys) & set(cli.args.filter))
53 rejected = set(cli.args.filter) - set(keys)
54 for key in rejected:
55 cli.log.info(f'{{fg_yellow}}Skipping {key} as migration not possible...')
56
57 cli.log.info(f'{{fg_green}}Migrating keyboard {{fg_cyan}}{cli.args.keyboard}{{fg_green}}.{{fg_reset}}')
58
59 # Start migration
60 for file in files:
61 cli.log.info(f' Migrating file {file}')
62 file_contents = file.read_text(encoding='utf-8').split('\n')
63 for key in keys:
64 for num, line in enumerate(file_contents):
65 if line.startswith(f'{key} =') or line.startswith(f'#define {key} '):
66 cli.log.info(f' Migrating {key}...')
67
68 while line.rstrip().endswith('\\'):
69 file_contents.pop(num)
70 line = file_contents[num]
71 file_contents.pop(num)
72
73 update_key = info_map[key]["info_key"]
74 if update_key in kb_info_json:
75 info_data[update_key] = kb_info_json[update_key]
76
77 file.write_text('\n'.join(file_contents), encoding='utf-8')
78
79 # Finally write out updated info.json
80 cli.log.info(f' Updating {target_info}')
81 target_info.write_text(json.dumps(info_data.to_dict(), cls=InfoJSONEncoder, sort_keys=True))
82
83 cli.log.info(f'{{fg_green}}Migration of keyboard {{fg_cyan}}{cli.args.keyboard}{{fg_green}} complete!{{fg_reset}}')
84 cli.log.info(f"Verify build with {{fg_yellow}}qmk compile -kb {cli.args.keyboard} -km default{{fg_reset}}.")
diff --git a/lib/python/qmk/cli/new/__init__.py b/lib/python/qmk/cli/new/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/new/__init__.py
diff --git a/lib/python/qmk/cli/new/keyboard.py b/lib/python/qmk/cli/new/keyboard.py
new file mode 100644
index 0000000000..bd02acf9c8
--- /dev/null
+++ b/lib/python/qmk/cli/new/keyboard.py
@@ -0,0 +1,273 @@
1"""This script automates the creation of new keyboard directories using a starter template.
2"""
3import re
4import json
5import shutil
6from datetime import date
7from pathlib import Path
8from dotty_dict import dotty
9
10from milc import cli
11from milc.questions import choice, question, yesno
12
13from qmk.git import git_get_username
14from qmk.json_schema import load_jsonschema
15from qmk.path import keyboard
16from qmk.json_encoders import InfoJSONEncoder
17from qmk.json_schema import deep_update
18from qmk.constants import MCU2BOOTLOADER, QMK_FIRMWARE
19
20COMMUNITY = Path('layouts/default/')
21TEMPLATE = Path('data/templates/keyboard/')
22
23# defaults
24schema = dotty(load_jsonschema('keyboard'))
25mcu_types = sorted(schema["properties.processor.enum"], key=str.casefold)
26dev_boards = sorted(schema["properties.development_board.enum"], key=str.casefold)
27available_layouts = sorted([x.name for x in COMMUNITY.iterdir() if x.is_dir()])
28
29
30def mcu_type(mcu):
31 """Callable for argparse validation.
32 """
33 if mcu not in (dev_boards + mcu_types):
34 raise ValueError
35 return mcu
36
37
38def layout_type(layout):
39 """Callable for argparse validation.
40 """
41 if layout not in available_layouts:
42 raise ValueError
43 return layout
44
45
46def keyboard_name(name):
47 """Callable for argparse validation.
48 """
49 if not validate_keyboard_name(name):
50 raise ValueError
51 return name
52
53
54def validate_keyboard_name(name):
55 """Returns True if the given keyboard name contains only lowercase a-z, 0-9 and underscore characters.
56 """
57 regex = re.compile(r'^[a-z0-9][a-z0-9/_]+$')
58 return bool(regex.match(name))
59
60
61def select_default_bootloader(mcu):
62 """Provide sane defaults for bootloader
63 """
64 return MCU2BOOTLOADER.get(mcu, "custom")
65
66
67def replace_placeholders(src, dest, tokens):
68 """Replaces the given placeholders in each template file.
69 """
70 content = src.read_text()
71 for key, value in tokens.items():
72 content = content.replace(f'%{key}%', value)
73
74 dest.write_text(content)
75
76
77def replace_string(src, token, value):
78 src.write_text(src.read_text().replace(token, value))
79
80
81def augment_community_info(config, src, dest):
82 """Splice in any additional data into info.json
83 """
84 info = json.loads(src.read_text())
85 template = json.loads(dest.read_text())
86
87 # merge community with template
88 deep_update(info, template)
89 deep_update(info, config)
90
91 # avoid assumptions on macro name by using the first available
92 first_layout = next(iter(info["layouts"].values()))["layout"]
93
94 # guess at width and height now its optional
95 width, height = (0, 0)
96 for item in first_layout:
97 width = max(width, int(item["x"]) + 1)
98 height = max(height, int(item["y"]) + 1)
99
100 info["matrix_pins"] = {
101 "cols": ["C2"] * width,
102 "rows": ["D1"] * height,
103 }
104
105 # assume a 1:1 mapping on matrix to electrical
106 for item in first_layout:
107 item["matrix"] = [int(item["y"]), int(item["x"])]
108
109 # finally write out the updated json
110 dest.write_text(json.dumps(info, cls=InfoJSONEncoder, sort_keys=True))
111
112
113def _question(*args, **kwargs):
114 """Ugly workaround until 'milc' learns to display a repromt msg
115 """
116 # TODO: Remove this once milc.questions.question handles reprompt messages
117
118 reprompt = kwargs["reprompt"]
119 del kwargs["reprompt"]
120 validate = kwargs["validate"]
121 del kwargs["validate"]
122
123 prompt = args[0]
124 ret = None
125 while not ret:
126 ret = question(prompt, **kwargs)
127 if not validate(ret):
128 ret = None
129 prompt = reprompt
130
131 return ret
132
133
134def prompt_heading_subheading(heading, subheading):
135 cli.log.info(f"{{fg_yellow}}{heading}{{style_reset_all}}")
136 cli.log.info(subheading)
137
138
139def prompt_keyboard():
140 prompt_heading_subheading("Name Your Keyboard Project", """For more information, see:
141https://docs.qmk.fm/hardware_keyboard_guidelines#naming-your-keyboard-project""")
142
143 errmsg = 'Keyboard already exists! Please choose a different name:'
144
145 return _question("Keyboard Name?", reprompt=errmsg, validate=lambda x: not keyboard(x).exists())
146
147
148def prompt_user():
149 prompt_heading_subheading("Attribution", "Used for maintainer, copyright, etc.")
150
151 return question("Your GitHub Username?", default=git_get_username())
152
153
154def prompt_name(def_name):
155 prompt_heading_subheading("More Attribution", "Used for maintainer, copyright, etc.")
156
157 return question("Your Real Name?", default=def_name)
158
159
160def prompt_layout():
161 prompt_heading_subheading("Pick Base Layout", """As a starting point, one of the common layouts can be used to
162bootstrap the process""")
163
164 # avoid overwhelming user - remove some?
165 filtered_layouts = [x for x in available_layouts if not any(xs in x for xs in ['_split', '_blocker', '_tsangan', '_f13'])]
166 filtered_layouts.append("none of the above")
167
168 return choice("Default Layout?", filtered_layouts, default=len(filtered_layouts) - 1)
169
170
171def prompt_mcu_type():
172 prompt_heading_subheading(
173 "What Powers Your Project", """Is your board using a separate development board, such as a Pro Micro,
174or is the microcontroller integrated onto the PCB?
175
176For more information, see:
177https://docs.qmk.fm/compatible_microcontrollers"""
178 )
179
180 return yesno("Using a Development Board?")
181
182
183def prompt_dev_board():
184 prompt_heading_subheading("Select Development Board", """For more information, see:
185https://docs.qmk.fm/compatible_microcontrollers""")
186
187 return choice("Development Board?", dev_boards, default=dev_boards.index("promicro"))
188
189
190def prompt_mcu():
191 prompt_heading_subheading("Select Microcontroller", """For more information, see:
192https://docs.qmk.fm/compatible_microcontrollers""")
193
194 # remove any options strictly used for compatibility
195 filtered_mcu = [x for x in mcu_types if not any(xs in x for xs in ['cortex', 'unknown'])]
196
197 return choice("Microcontroller?", filtered_mcu, default=filtered_mcu.index("atmega32u4"))
198
199
200@cli.argument('-kb', '--keyboard', help='Specify the name for the new keyboard directory', arg_only=True, type=keyboard_name)
201@cli.argument('-l', '--layout', help='Community layout to bootstrap with', arg_only=True, type=layout_type)
202@cli.argument('-t', '--type', help='Specify the keyboard MCU type (or "development_board" preset)', arg_only=True, type=mcu_type)
203@cli.argument('-u', '--username', help='Specify your username (default from Git config)', dest='name')
204@cli.argument('-n', '--realname', help='Specify your real name if you want to use that. Defaults to username', arg_only=True)
205@cli.subcommand('Creates a new keyboard directory')
206def new_keyboard(cli):
207 """Creates a new keyboard.
208 """
209 cli.log.info('{style_bright}Generating a new QMK keyboard directory{style_normal}')
210 cli.echo('')
211
212 kb_name = cli.args.keyboard if cli.args.keyboard else prompt_keyboard()
213 if not validate_keyboard_name(kb_name):
214 cli.log.error('Keyboard names must contain only {fg_cyan}lowercase a-z{fg_reset}, {fg_cyan}0-9{fg_reset}, and {fg_cyan}_{fg_reset}! Please choose a different name.')
215 return 1
216
217 if keyboard(kb_name).exists():
218 cli.log.error(f'Keyboard {{fg_cyan}}{kb_name}{{fg_reset}} already exists! Please choose a different name.')
219 return 1
220
221 user_name = cli.config.new_keyboard.name if cli.config.new_keyboard.name else prompt_user()
222 real_name = cli.args.realname or cli.config.new_keyboard.name if cli.args.realname or cli.config.new_keyboard.name else prompt_name(user_name)
223 default_layout = cli.args.layout if cli.args.layout else prompt_layout()
224
225 if cli.args.type:
226 mcu = cli.args.type
227 else:
228 mcu = prompt_dev_board() if prompt_mcu_type() else prompt_mcu()
229
230 config = {}
231 if mcu in dev_boards:
232 config['development_board'] = mcu
233 else:
234 config['processor'] = mcu
235 config['bootloader'] = select_default_bootloader(mcu)
236
237 detach_layout = False
238 if default_layout == 'none of the above':
239 default_layout = "ortho_4x4"
240 detach_layout = True
241
242 tokens = { # Comment here is to force multiline formatting
243 'YEAR': str(date.today().year),
244 'KEYBOARD': kb_name,
245 'USER_NAME': user_name,
246 'REAL_NAME': real_name
247 }
248
249 # begin with making the deepest folder in the tree
250 keymaps_path = keyboard(kb_name) / 'keymaps/'
251 keymaps_path.mkdir(parents=True)
252
253 # copy in keymap.c or keymap.json
254 community_keymap = Path(COMMUNITY / f'{default_layout}/default_{default_layout}/')
255 shutil.copytree(community_keymap, keymaps_path / 'default')
256
257 # process template files
258 for file in list(TEMPLATE.iterdir()):
259 replace_placeholders(file, keyboard(kb_name) / file.name, tokens)
260
261 # merge in infos
262 community_info = Path(COMMUNITY / f'{default_layout}/info.json')
263 augment_community_info(config, community_info, keyboard(kb_name) / 'keyboard.json')
264
265 # detach community layout and rename to just "LAYOUT"
266 if detach_layout:
267 replace_string(keyboard(kb_name) / 'keyboard.json', 'LAYOUT_ortho_4x4', 'LAYOUT')
268 replace_string(keymaps_path / 'default/keymap.c', 'LAYOUT_ortho_4x4', 'LAYOUT')
269
270 cli.log.info(f'{{fg_green}}Created a new keyboard called {{fg_cyan}}{kb_name}{{fg_green}}.{{fg_reset}}')
271 cli.log.info(f"Build Command: {{fg_yellow}}qmk compile -kb {kb_name} -km default{{fg_reset}}.")
272 cli.log.info(f'Project Location: {{fg_cyan}}{QMK_FIRMWARE}/{keyboard(kb_name)}{{fg_reset}}.')
273 cli.log.info("{fg_yellow}Now update the config files to match the hardware!{fg_reset}")
diff --git a/lib/python/qmk/cli/new/keymap.py b/lib/python/qmk/cli/new/keymap.py
new file mode 100755
index 0000000000..4d19a726a4
--- /dev/null
+++ b/lib/python/qmk/cli/new/keymap.py
@@ -0,0 +1,150 @@
1"""This script automates the copying of the default keymap into your own keymap.
2"""
3import re
4import json
5import shutil
6from pathlib import Path
7
8from milc import cli
9from milc.questions import question, choice
10
11from qmk.constants import HAS_QMK_USERSPACE, QMK_USERSPACE
12from qmk.errors import NoSuchKeyboardError
13from qmk.path import is_keyboard, keymaps, keymap
14from qmk.git import git_get_username
15from qmk.decorators import automagic_keyboard, automagic_keymap
16from qmk.keyboard import keyboard_completer, keyboard_folder
17from qmk.userspace import UserspaceDefs
18from qmk.json_schema import json_load
19from qmk.json_encoders import KeymapJSONEncoder
20from qmk.info import info_json
21
22
23def _list_available_converters(kb_name):
24 """Search for converters that can be applied to a given keyboard
25 """
26 if not is_keyboard(kb_name):
27 return None
28
29 info = info_json(kb_name)
30 pin_compatible = info.get('pin_compatible')
31 if not pin_compatible:
32 return None
33
34 return sorted(folder.name.split('_to_')[-1] for folder in Path('platforms').glob(f'*/converters/{pin_compatible}_to_*'))
35
36
37def _set_converter(file, converter):
38 """add/overwrite any existing converter specified in keymap.json
39 """
40 json_data = json_load(file) if file.exists() else {}
41
42 json_data['converter'] = converter
43
44 output = json.dumps(json_data, cls=KeymapJSONEncoder, sort_keys=True)
45 file.write_text(output + '\n', encoding='utf-8')
46
47
48def validate_keymap_name(name):
49 """Returns True if the given keymap name contains only a-z, 0-9 and underscore characters.
50 """
51 regex = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_]+$')
52 return bool(regex.match(name))
53
54
55def prompt_keyboard():
56 prompt = """{fg_yellow}Select Keyboard{style_reset_all}
57If you're unsure you can view a full list of supported keyboards with {fg_yellow}qmk list-keyboards{style_reset_all}.
58
59Keyboard Name? """
60 return question(prompt)
61
62
63def prompt_user():
64 prompt = """
65{fg_yellow}Name Your Keymap{style_reset_all}
66
67Keymap name? """
68 return question(prompt, default=git_get_username())
69
70
71def prompt_converter(kb_name):
72 prompt = """
73{fg_yellow}Configure Development Board{style_reset_all}
74For more information, see:
75https://docs.qmk.fm/feature_converters
76
77Use converter? """
78
79 converters = _list_available_converters(kb_name)
80 if not converters:
81 return None
82
83 choices = ['No (default)', *converters]
84
85 answer = choice(prompt, options=choices, default=0)
86 return None if choices.index(answer) == 0 else answer
87
88
89@cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='Specify keyboard name. Example: 1upkeyboards/1up60hse')
90@cli.argument('-km', '--keymap', help='Specify the name for the new keymap directory')
91@cli.argument('--converter', help='Specify the name of a converter to configure')
92@cli.argument('--skip-converter', arg_only=True, action='store_true', help='Skip converter')
93@cli.subcommand('Creates a new keymap for the keyboard of your choosing')
94@automagic_keyboard
95@automagic_keymap
96def new_keymap(cli):
97 """Creates a new keymap for the keyboard of your choosing.
98 """
99 cli.log.info('{style_bright}Generating a new keymap{style_normal}')
100 cli.echo('')
101
102 # ask for user input if keyboard or keymap was not provided in the command line
103 kb_name = cli.config.new_keymap.keyboard if cli.config.new_keymap.keyboard else prompt_keyboard()
104 user_name = cli.config.new_keymap.keymap if cli.config.new_keymap.keymap else prompt_user()
105 converter = cli.config.new_keymap.converter if cli.args.skip_converter or cli.config.new_keymap.converter else prompt_converter(kb_name)
106
107 # check directories
108 try:
109 kb_name = keyboard_folder(kb_name)
110 except ValueError:
111 cli.log.error(f'Keyboard {{fg_cyan}}{kb_name}{{fg_reset}} does not exist! Please choose a valid name.')
112 return False
113
114 # validate before any keymap ops
115 try:
116 keymaps_dirs = keymaps(kb_name)
117 keymap_path_new = keymaps_dirs[0] / user_name
118 except NoSuchKeyboardError:
119 cli.log.error(f'Keymap folder for {{fg_cyan}}{kb_name}{{fg_reset}} does not exist!')
120 return False
121
122 keymap_path_default = keymap(kb_name, 'default')
123
124 if not keymap_path_default:
125 cli.log.error(f'Default keymap for {{fg_cyan}}{kb_name}{{fg_reset}} does not exist!')
126 return False
127
128 if not validate_keymap_name(user_name):
129 cli.log.error('Keymap names must contain only {fg_cyan}a-z{fg_reset}, {fg_cyan}0-9{fg_reset} and {fg_cyan}_{fg_reset}! Please choose a different name.')
130 return False
131
132 if keymap_path_new.exists():
133 cli.log.error(f'Keymap {{fg_cyan}}{user_name}{{fg_reset}} already exists! Please choose a different name.')
134 return False
135
136 # create user directory with default keymap files
137 shutil.copytree(keymap_path_default, keymap_path_new, symlinks=True)
138
139 if converter:
140 _set_converter(keymap_path_new / 'keymap.json', converter)
141
142 # end message to user
143 cli.log.info(f'{{fg_green}}Created a new keymap called {{fg_cyan}}{user_name}{{fg_green}} in: {{fg_cyan}}{keymap_path_new}{{fg_reset}}.')
144 cli.log.info(f"Compile a firmware with your new keymap by typing: {{fg_yellow}}qmk compile -kb {kb_name} -km {user_name}{{fg_reset}}.")
145
146 # Add to userspace compile if we have userspace available
147 if HAS_QMK_USERSPACE:
148 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
149 userspace.add_target(keyboard=kb_name, keymap=user_name, do_print=False)
150 return userspace.save()
diff --git a/lib/python/qmk/cli/painter/__init__.py b/lib/python/qmk/cli/painter/__init__.py
new file mode 100644
index 0000000000..d1a225346c
--- /dev/null
+++ b/lib/python/qmk/cli/painter/__init__.py
@@ -0,0 +1,2 @@
1from . import convert_graphics
2from . import make_font
diff --git a/lib/python/qmk/cli/painter/convert_graphics.py b/lib/python/qmk/cli/painter/convert_graphics.py
new file mode 100644
index 0000000000..f74d655fd5
--- /dev/null
+++ b/lib/python/qmk/cli/painter/convert_graphics.py
@@ -0,0 +1,77 @@
1"""This script tests QGF functionality.
2"""
3from io import BytesIO
4from qmk.path import normpath
5from qmk.painter import generate_subs, render_header, render_source, valid_formats
6from milc import cli
7from PIL import Image
8
9
10@cli.argument('-v', '--verbose', arg_only=True, action='store_true', help='Turns on verbose output.')
11@cli.argument('-i', '--input', required=True, help='Specify input graphic file.')
12@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
13@cli.argument('-f', '--format', required=True, help=f'Output format, valid types: {", ".join(valid_formats.keys())}')
14@cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disables the use of RLE when encoding images.')
15@cli.argument('-d', '--no-deltas', arg_only=True, action='store_true', help='Disables the use of delta frames when encoding animations.')
16@cli.argument('-w', '--raw', arg_only=True, action='store_true', help='Writes out the QGF file as raw data instead of c/h combo.')
17@cli.subcommand('Converts an input image to something QMK understands')
18def painter_convert_graphics(cli):
19 """Converts an image file to a format that Quantum Painter understands.
20
21 This command uses the `qmk.painter` module to generate a Quantum Painter image defintion from an image. The generated definitions are written to a files next to the input -- `INPUT.c` and `INPUT.h`.
22 """
23 # Work out the input file
24 if cli.args.input != '-':
25 cli.args.input = normpath(cli.args.input)
26
27 # Error checking
28 if not cli.args.input.exists():
29 cli.log.error('Input image file does not exist!')
30 cli.print_usage()
31 return False
32
33 # Work out the output directory
34 if len(cli.args.output) == 0:
35 cli.args.output = cli.args.input.parent
36 cli.args.output = normpath(cli.args.output)
37
38 # Ensure we have a valid format
39 if cli.args.format not in valid_formats.keys():
40 cli.log.error('Output format %s is invalid. Allowed values: %s' % (cli.args.format, ', '.join(valid_formats.keys())))
41 cli.print_usage()
42 return False
43
44 # Work out the encoding parameters
45 format = valid_formats[cli.args.format]
46
47 # Load the input image
48 input_img = Image.open(cli.args.input)
49
50 # Convert the image to QGF using PIL
51 out_data = BytesIO()
52 metadata = []
53 input_img.save(out_data, "QGF", use_deltas=(not cli.args.no_deltas), use_rle=(not cli.args.no_rle), qmk_format=format, verbose=cli.args.verbose, metadata=metadata)
54 out_bytes = out_data.getvalue()
55
56 if cli.args.raw:
57 raw_file = cli.args.output / f"{cli.args.input.stem}.qgf"
58 with open(raw_file, 'wb') as raw:
59 raw.write(out_bytes)
60 return
61
62 # Work out the text substitutions for rendering the output data
63 subs = generate_subs(cli, out_bytes, image_metadata=metadata, command_name="painter_convert_graphics")
64
65 # Render and write the header file
66 header_text = render_header(subs)
67 header_file = cli.args.output / f"{cli.args.input.stem}.qgf.h"
68 with open(header_file, 'w') as header:
69 print(f"Writing {header_file}...")
70 header.write(header_text)
71
72 # Render and write the source file
73 source_text = render_source(subs)
74 source_file = cli.args.output / f"{cli.args.input.stem}.qgf.c"
75 with open(source_file, 'w') as source:
76 print(f"Writing {source_file}...")
77 source.write(source_text)
diff --git a/lib/python/qmk/cli/painter/make_font.py b/lib/python/qmk/cli/painter/make_font.py
new file mode 100644
index 0000000000..3e18fd74a5
--- /dev/null
+++ b/lib/python/qmk/cli/painter/make_font.py
@@ -0,0 +1,79 @@
1"""This script automates the conversion of font files into a format QMK firmware understands.
2"""
3
4from io import BytesIO
5from qmk.path import normpath
6from qmk.painter_qff import _generate_font_glyphs_list, QFFFont
7from qmk.painter import generate_subs, render_header, render_source, valid_formats
8from milc import cli
9
10
11@cli.argument('-f', '--font', required=True, help='Specify input font file.')
12@cli.argument('-o', '--output', required=True, help='Specify output image path.')
13@cli.argument('-s', '--size', default=12, help='Specify font size. Default 12.')
14@cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
15@cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.')
16@cli.argument('-a', '--no-aa', arg_only=True, action='store_true', help='Disable anti-aliasing on fonts.')
17@cli.subcommand('Converts an input font to something QMK understands')
18def painter_make_font_image(cli):
19 # Create the font object
20 font = QFFFont(cli)
21 # Read from the input file
22 cli.args.font = normpath(cli.args.font)
23 font.generate_image(cli.args.font, cli.args.size, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs, use_aa=(False if cli.args.no_aa else True))
24 # Render out the data
25 font.save_to_image(normpath(cli.args.output))
26
27
28@cli.argument('-i', '--input', help='Specify input graphic file.')
29@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
30@cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
31@cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.')
32@cli.argument('-f', '--format', required=True, help=f'Output format, valid types: {", ".join(valid_formats.keys())}')
33@cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disable the use of RLE to minimise converted image size.')
34@cli.argument('-w', '--raw', arg_only=True, action='store_true', help='Writes out the QFF file as raw data instead of c/h combo.')
35@cli.subcommand('Converts an input font image to something QMK firmware understands')
36def painter_convert_font_image(cli):
37 # Work out the format
38 format = valid_formats[cli.args.format]
39
40 # Create the font object
41 font = QFFFont(cli.log)
42
43 # Read from the input file
44 cli.args.input = normpath(cli.args.input)
45 font.read_from_image(cli.args.input, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs)
46
47 # Work out the output directory
48 if len(cli.args.output) == 0:
49 cli.args.output = cli.args.input.parent
50 cli.args.output = normpath(cli.args.output)
51
52 # Render out the data
53 out_data = BytesIO()
54 font.save_to_qff(format, not cli.args.no_rle, out_data)
55 out_bytes = out_data.getvalue()
56
57 if cli.args.raw:
58 raw_file = cli.args.output / f"{cli.args.input.stem}.qff"
59 with open(raw_file, 'wb') as raw:
60 raw.write(out_bytes)
61 return
62
63 # Work out the text substitutions for rendering the output data
64 metadata = {"glyphs": _generate_font_glyphs_list(not cli.args.no_ascii, cli.args.unicode_glyphs)}
65 subs = generate_subs(cli, out_bytes, font_metadata=metadata, command_name="painter_convert_font_image")
66
67 # Render and write the header file
68 header_text = render_header(subs)
69 header_file = cli.args.output / f"{cli.args.input.stem}.qff.h"
70 with open(header_file, 'w') as header:
71 print(f"Writing {header_file}...")
72 header.write(header_text)
73
74 # Render and write the source file
75 source_text = render_source(subs)
76 source_file = cli.args.output / f"{cli.args.input.stem}.qff.c"
77 with open(source_file, 'w') as source:
78 print(f"Writing {source_file}...")
79 source.write(source_text)
diff --git a/lib/python/qmk/cli/pytest.py b/lib/python/qmk/cli/pytest.py
new file mode 100644
index 0000000000..5c9c173caa
--- /dev/null
+++ b/lib/python/qmk/cli/pytest.py
@@ -0,0 +1,18 @@
1"""QMK Python Unit Tests
2
3QMK script to run unit and integration tests against our python code.
4"""
5from subprocess import DEVNULL
6
7from milc import cli
8
9
10@cli.argument('-t', '--test', arg_only=True, action='append', default=[], help="Mapped to nose2 'testNames' positional argument - https://docs.nose2.io/en/latest/usage.html#specifying-tests-to-run")
11@cli.subcommand('QMK Python Unit Tests', hidden=False if cli.config.user.developer else True)
12def pytest(cli):
13 """Run several linting/testing commands.
14 """
15 nose2 = cli.run(['nose2', '-v', '-t', 'lib/python', *cli.args.test], capture_output=False, stdin=DEVNULL)
16 flake8 = cli.run(['flake8', 'lib/python'], capture_output=False, stdin=DEVNULL)
17
18 return flake8.returncode | nose2.returncode
diff --git a/lib/python/qmk/cli/resolve_alias.py b/lib/python/qmk/cli/resolve_alias.py
new file mode 100644
index 0000000000..dff2242b28
--- /dev/null
+++ b/lib/python/qmk/cli/resolve_alias.py
@@ -0,0 +1,16 @@
1from qmk.keyboard import keyboard_folder
2
3from milc import cli
4
5
6@cli.argument('--allow-unknown', arg_only=True, action='store_true', help="Return original if rule is not a valid keyboard.")
7@cli.argument('keyboard', arg_only=True, help='The keyboard\'s name')
8@cli.subcommand('Resolve any keyboard_aliases for provided rule')
9def resolve_alias(cli):
10 try:
11 print(keyboard_folder(cli.args.keyboard))
12 except ValueError:
13 if cli.args.allow_unknown:
14 print(cli.args.keyboard)
15 else:
16 raise
diff --git a/lib/python/qmk/cli/test/__init__.py b/lib/python/qmk/cli/test/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/cli/test/__init__.py
diff --git a/lib/python/qmk/cli/test/c.py b/lib/python/qmk/cli/test/c.py
new file mode 100644
index 0000000000..7a4e20d5e6
--- /dev/null
+++ b/lib/python/qmk/cli/test/c.py
@@ -0,0 +1,47 @@
1import fnmatch
2import re
3from subprocess import DEVNULL
4
5from milc import cli
6
7from qmk.commands import find_make, get_make_parallel_args, build_environment
8
9
10@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
11@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.")
12@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
13@cli.argument('-l', '--list', arg_only=True, action='store_true', help='List available tests.')
14@cli.argument('-t', '--test', arg_only=True, action='append', default=[], help="Test to run from the available list. Supports wildcard globs. May be passed multiple times.")
15@cli.subcommand("QMK C Unit Tests.", hidden=False if cli.config.user.developer else True)
16def test_c(cli):
17 """Run native unit tests.
18 """
19 list_tests = cli.run([find_make(), 'list-tests', 'SILENT=true'])
20 available_tests = sorted(list_tests.stdout.strip().split())
21
22 if cli.args.list:
23 return print("\n".join(available_tests))
24
25 # expand any wildcards
26 filtered_tests = set()
27 for test in cli.args.test:
28 regex = re.compile(fnmatch.translate(test))
29 filtered_tests |= set(filter(regex.match, available_tests))
30
31 for invalid in filtered_tests - set(available_tests):
32 cli.log.warning(f'Invalid test provided: {invalid}')
33
34 # convert test names to build targets
35 targets = list(map(lambda x: f'test:{x}', filtered_tests or ['all']))
36
37 if cli.args.clean:
38 targets.insert(0, 'clean')
39
40 # Add in the environment vars
41 for key, value in build_environment(cli.args.env).items():
42 targets.append(f'{key}={value}')
43
44 command = [find_make(), *get_make_parallel_args(cli.config.test_c.parallel), *targets]
45
46 cli.log.info('Compiling tests with {fg_cyan}%s', ' '.join(command))
47 return cli.run(command, capture_output=False, stdin=DEVNULL).returncode
diff --git a/lib/python/qmk/cli/userspace/__init__.py b/lib/python/qmk/cli/userspace/__init__.py
new file mode 100644
index 0000000000..5757d3a4c9
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/__init__.py
@@ -0,0 +1,5 @@
1from . import doctor
2from . import add
3from . import remove
4from . import list
5from . import compile
diff --git a/lib/python/qmk/cli/userspace/add.py b/lib/python/qmk/cli/userspace/add.py
new file mode 100644
index 0000000000..eea70efb95
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/add.py
@@ -0,0 +1,56 @@
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path
4from milc import cli
5
6from qmk.commands import parse_env_vars
7from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
8from qmk.keyboard import keyboard_completer, keyboard_folder_or_all
9from qmk.keymap import keymap_completer, is_keymap_target
10from qmk.userspace import UserspaceDefs
11
12
13@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.")
14@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
15@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
16@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Extra variables to set during build. May be passed multiple times.")
17@cli.subcommand('Adds a build target to userspace `qmk.json`.')
18def userspace_add(cli):
19 if not HAS_QMK_USERSPACE:
20 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
21 return False
22
23 build_env = None if len(cli.args.env) == 0 else parse_env_vars(cli.args.env)
24
25 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
26
27 if len(cli.args.builds) > 0:
28 json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)])
29 make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds))
30
31 for e in json_like_targets:
32 userspace.add_target(json_path=e)
33
34 for e in make_like_targets:
35 s = e.split(':')
36 userspace.add_target(keyboard=s[0], keymap=s[1])
37
38 else:
39 failed = False
40 try:
41 if not is_keymap_target(cli.args.keyboard, cli.args.keymap):
42 failed = True
43 except KeyError:
44 failed = True
45
46 if failed:
47 from qmk.cli.new.keymap import new_keymap
48 cli.config.new_keymap.keyboard = cli.args.keyboard
49 cli.config.new_keymap.keymap = cli.args.keymap
50 cli.args.skip_converter = True
51 if new_keymap(cli) is not False:
52 userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap, build_env=build_env)
53 else:
54 userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap, build_env=build_env)
55
56 return userspace.save()
diff --git a/lib/python/qmk/cli/userspace/compile.py b/lib/python/qmk/cli/userspace/compile.py
new file mode 100644
index 0000000000..64fa3ed0c9
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/compile.py
@@ -0,0 +1,46 @@
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path
4from milc import cli
5
6from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
7from qmk.commands import build_environment
8from qmk.userspace import UserspaceDefs
9from qmk.build_targets import JsonKeymapBuildTarget
10from qmk.search import search_keymap_targets
11from qmk.cli.mass_compile import mass_compile_targets
12from qmk.util import maybe_exit_config
13
14
15def _extra_arg_setter(target, extra_args):
16 target.extra_args = extra_args
17
18
19@cli.argument('-t', '--no-temp', arg_only=True, action='store_true', help="Remove temporary files during build.")
20@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
21@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
22@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually build, just show the commands to be run.")
23@cli.argument('-p', '--print-failures', arg_only=True, action='store_true', help="Print failed builds.")
24@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.")
25@cli.subcommand('Compiles the build targets specified in userspace `qmk.json`.')
26def userspace_compile(cli):
27 if not HAS_QMK_USERSPACE:
28 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
29 return False
30
31 maybe_exit_config(should_exit=False, should_reraise=True)
32
33 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
34
35 build_targets = []
36 keyboard_keymap_targets = []
37 for e in userspace.build_targets:
38 if isinstance(e, Path):
39 build_targets.append(JsonKeymapBuildTarget(e))
40 elif isinstance(e, dict):
41 f = e['env'] if 'env' in e else None
42 keyboard_keymap_targets.append((e['keyboard'], e['keymap'], f))
43 if len(keyboard_keymap_targets) > 0:
44 build_targets.extend(search_keymap_targets(keyboard_keymap_targets))
45
46 return mass_compile_targets(list(set(build_targets)), cli.args.clean, cli.args.dry_run, cli.config.userspace_compile.no_temp, cli.config.userspace_compile.parallel, cli.args.print_failures, **build_environment(cli.args.env))
diff --git a/lib/python/qmk/cli/userspace/doctor.py b/lib/python/qmk/cli/userspace/doctor.py
new file mode 100644
index 0000000000..7c016e5a2f
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/doctor.py
@@ -0,0 +1,13 @@
1# Copyright 2023 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from milc import cli
4
5from qmk.constants import QMK_FIRMWARE, HAS_QMK_USERSPACE
6from qmk.cli.doctor.main import userspace_tests
7
8
9@cli.subcommand('Checks userspace configuration.')
10def userspace_doctor(cli):
11 userspace_tests(QMK_FIRMWARE)
12
13 return 0 if HAS_QMK_USERSPACE else 1
diff --git a/lib/python/qmk/cli/userspace/list.py b/lib/python/qmk/cli/userspace/list.py
new file mode 100644
index 0000000000..e902483b6b
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/list.py
@@ -0,0 +1,69 @@
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path
4from dotty_dict import Dotty
5from milc import cli
6
7from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
8from qmk.userspace import UserspaceDefs
9from qmk.build_targets import BuildTarget
10from qmk.keyboard import is_all_keyboards, keyboard_folder
11from qmk.keymap import is_keymap_target
12from qmk.search import search_keymap_targets
13from qmk.util import maybe_exit_config
14
15
16def _extra_arg_setter(target, extra_args):
17 target.extra_args = extra_args
18
19
20@cli.argument('-e', '--expand', arg_only=True, action='store_true', help="Expands any use of `all` for either keyboard or keymap.")
21@cli.subcommand('Lists the build targets specified in userspace `qmk.json`.')
22def userspace_list(cli):
23 if not HAS_QMK_USERSPACE:
24 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
25 return False
26
27 maybe_exit_config(should_exit=False, should_reraise=True)
28
29 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
30
31 if cli.args.expand:
32 build_targets = []
33 keyboard_keymap_targets = []
34 for e in userspace.build_targets:
35 if isinstance(e, Path):
36 build_targets.append(e)
37 elif isinstance(e, dict) or isinstance(e, Dotty):
38 f = e['env'] if 'env' in e else None
39 keyboard_keymap_targets.append((e['keyboard'], e['keymap'], f))
40 if len(keyboard_keymap_targets) > 0:
41 build_targets.extend(search_keymap_targets(keyboard_keymap_targets))
42 else:
43 build_targets = userspace.build_targets
44
45 for e in build_targets:
46 if isinstance(e, Path):
47 # JSON keymap from userspace
48 cli.log.info(f'JSON keymap: {{fg_cyan}}{e}{{fg_reset}}')
49 continue
50 elif isinstance(e, dict) or isinstance(e, Dotty):
51 # keyboard/keymap dict from userspace
52 keyboard = e['keyboard']
53 keymap = e['keymap']
54 extra_args = e.get('env')
55 elif isinstance(e, BuildTarget):
56 # BuildTarget from search_keymap_targets()
57 keyboard = e.keyboard
58 keymap = e.keymap
59 extra_args = e.extra_args
60
61 extra_args_str = ''
62 if extra_args is not None and len(extra_args) > 0:
63 extra_args_str = ', '.join([f'{{fg_cyan}}{k}={v}{{fg_reset}}' for k, v in extra_args.items()])
64 extra_args_str = f' ({{fg_cyan}}{extra_args_str}{{fg_reset}})'
65
66 if is_all_keyboards(keyboard) or is_keymap_target(keyboard_folder(keyboard), keymap):
67 cli.log.info(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}{extra_args_str}')
68 else:
69 cli.log.warning(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}{extra_args_str} -- not found!')
diff --git a/lib/python/qmk/cli/userspace/path.py b/lib/python/qmk/cli/userspace/path.py
new file mode 100755
index 0000000000..d0c1b544fb
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/path.py
@@ -0,0 +1,8 @@
1from milc import cli
2from qmk.constants import QMK_USERSPACE
3
4
5@cli.subcommand('Detected path to QMK Userspace.', hidden=True)
6def userspace_path(cli):
7 print(QMK_USERSPACE or '')
8 return
diff --git a/lib/python/qmk/cli/userspace/remove.py b/lib/python/qmk/cli/userspace/remove.py
new file mode 100644
index 0000000000..b2da08a98e
--- /dev/null
+++ b/lib/python/qmk/cli/userspace/remove.py
@@ -0,0 +1,41 @@
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path
4from milc import cli
5
6from qmk.commands import parse_env_vars
7from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
8from qmk.keyboard import keyboard_completer, keyboard_folder_or_all
9from qmk.keymap import keymap_completer
10from qmk.userspace import UserspaceDefs
11
12
13@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.")
14@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
15@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
16@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Extra variables to set during build. May be passed multiple times.")
17@cli.subcommand('Removes a build target from userspace `qmk.json`.')
18def userspace_remove(cli):
19 if not HAS_QMK_USERSPACE:
20 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
21 return False
22
23 build_env = None if len(cli.args.env) == 0 else parse_env_vars(cli.args.env)
24
25 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
26
27 if len(cli.args.builds) > 0:
28 json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)])
29 make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds))
30
31 for e in json_like_targets:
32 userspace.remove_target(json_path=e)
33
34 for e in make_like_targets:
35 s = e.split(':')
36 userspace.remove_target(keyboard=s[0], keymap=s[1], build_env=build_env)
37
38 else:
39 userspace.remove_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap, build_env=build_env)
40
41 return userspace.save()
diff --git a/lib/python/qmk/cli/via2json.py b/lib/python/qmk/cli/via2json.py
new file mode 100755
index 0000000000..0997e9ca9f
--- /dev/null
+++ b/lib/python/qmk/cli/via2json.py
@@ -0,0 +1,159 @@
1"""Generate a keymap.c from a configurator export.
2"""
3import json
4import re
5
6from milc import cli
7
8import qmk.keyboard
9import qmk.path
10from qmk.info import info_json
11from qmk.json_encoders import KeymapJSONEncoder
12from qmk.commands import dump_lines
13from qmk.keymap import generate_json
14
15
16def _find_via_layout_macro(keyboard_data):
17 """Assume layout macro when only 1 is available
18 """
19 layouts = list(keyboard_data['layouts'].keys())
20 return layouts[0] if len(layouts) == 1 else None
21
22
23def _convert_macros(via_macros):
24 via_macros = list(filter(lambda f: bool(f), via_macros))
25 if len(via_macros) == 0:
26 return list()
27 split_regex = re.compile(r'(}\,)|(\,{)')
28 macro_group_regex = re.compile(r'({.+?})')
29 macros = list()
30 for via_macro in via_macros:
31 # Split VIA macro to its elements
32 macro = split_regex.split(via_macro)
33 # Remove junk elements (None, '},' and ',{')
34 macro = list(filter(lambda f: False if f in (None, '},', ',{') else True, macro))
35 macro_data = list()
36 for m in macro:
37 if '{' in m or '}' in m:
38 # Split macro groups
39 macro_groups = macro_group_regex.findall(m)
40 for macro_group in macro_groups:
41 # Remove whitespaces and curly braces from around group
42 macro_group = macro_group.strip(' {}')
43
44 macro_action = 'tap'
45 macro_keycodes = []
46
47 if macro_group[0] == '+':
48 macro_action = 'down'
49 macro_keycodes.append(macro_group[1:])
50 elif macro_group[0] == '-':
51 macro_action = 'up'
52 macro_keycodes.append(macro_group[1:])
53 else:
54 macro_keycodes.extend(macro_group.split(',') if ',' in macro_group else [macro_group])
55
56 # Remove the KC prefixes
57 macro_keycodes = list(map(lambda s: s.replace('KC_', ''), macro_keycodes))
58
59 macro_data.append({"action": macro_action, "keycodes": macro_keycodes})
60 else:
61 # Found text
62 macro_data.append(m)
63 macros.append(macro_data)
64
65 return macros
66
67
68def _fix_macro_keys(keymap_data):
69 macro_no = re.compile(r'MACRO0?\(([0-9]{1,2})\)')
70 for i in range(0, len(keymap_data)):
71 for j in range(0, len(keymap_data[i])):
72 kc = keymap_data[i][j]
73 m = macro_no.match(kc)
74 if m:
75 keymap_data[i][j] = f'MC_{m.group(1)}'
76 return keymap_data
77
78
79def _via_to_keymap(via_backup, keyboard_data, keymap_layout):
80 # Check if passed LAYOUT is correct
81 layout_data = keyboard_data['layouts'].get(keymap_layout)
82 if not layout_data:
83 cli.log.error(f'LAYOUT macro {keymap_layout} is not a valid one for keyboard {cli.args.keyboard}!')
84 return None
85
86 layout_data = layout_data['layout']
87 sorting_hat = list()
88 for index, data in enumerate(layout_data):
89 sorting_hat.append([index, data['matrix']])
90
91 sorting_hat.sort(key=lambda k: (k[1][0], k[1][1]))
92
93 pos = 0
94 for row_num in range(0, keyboard_data['matrix_size']['rows']):
95 for col_num in range(0, keyboard_data['matrix_size']['cols']):
96 if pos >= len(sorting_hat) or sorting_hat[pos][1][0] != row_num or sorting_hat[pos][1][1] != col_num:
97 sorting_hat.insert(pos, [None, [row_num, col_num]])
98 else:
99 sorting_hat.append([None, [row_num, col_num]])
100 pos += 1
101
102 keymap_data = list()
103 for layer in via_backup['layers']:
104 pos = 0
105 layer_data = list()
106 for key in layer:
107 if sorting_hat[pos][0] is not None:
108 layer_data.append([sorting_hat[pos][0], key])
109 pos += 1
110 layer_data.sort()
111 layer_data = [kc[1] for kc in layer_data]
112 keymap_data.append(layer_data)
113
114 return keymap_data
115
116
117@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
118@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
119@cli.argument('filename', type=qmk.path.FileType('r'), arg_only=True, help='VIA Backup JSON file')
120@cli.argument('-kb', '--keyboard', type=qmk.keyboard.keyboard_folder, completer=qmk.keyboard.keyboard_completer, arg_only=True, required=True, help='The keyboard\'s name')
121@cli.argument('-km', '--keymap', arg_only=True, default='via2json', help='The keymap\'s name')
122@cli.argument('-l', '--layout', arg_only=True, help='The keymap\'s layout')
123@cli.subcommand('Convert a VIA backup json to keymap.json format.')
124def via2json(cli):
125 """Convert a VIA backup json to keymap.json format.
126
127 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.
128 """
129 # Load the VIA backup json
130 with cli.args.filename.open('r') as fd:
131 via_backup = json.load(fd)
132
133 keyboard_data = info_json(cli.args.keyboard)
134
135 # Find appropriate layout macro
136 keymap_layout = cli.args.layout if cli.args.layout else _find_via_layout_macro(keyboard_data)
137 if not keymap_layout:
138 cli.log.error(f"Couldn't find LAYOUT macro for keyboard {cli.args.keyboard}. Please specify it with the '-l' argument.")
139 return False
140
141 # Get keycode array
142 keymap_data = _via_to_keymap(via_backup, keyboard_data, keymap_layout)
143 if not keymap_data:
144 cli.log.error(f'Could not extract valid keycode data from VIA backup matching keyboard {cli.args.keyboard}!')
145 return False
146
147 # Convert macros
148 macro_data = list()
149 if via_backup.get('macros'):
150 macro_data = _convert_macros(via_backup['macros'])
151
152 # Replace VIA macro keys with JSON keymap ones
153 keymap_data = _fix_macro_keys(keymap_data)
154
155 # Generate the keymap.json
156 keymap_json = generate_json(cli.args.keymap, cli.args.keyboard, keymap_layout, keymap_data, macro_data)
157
158 keymap_lines = [json.dumps(keymap_json, cls=KeymapJSONEncoder, sort_keys=True)]
159 dump_lines(cli.args.output, keymap_lines, cli.args.quiet)
diff --git a/lib/python/qmk/commands.py b/lib/python/qmk/commands.py
new file mode 100644
index 0000000000..ac1455967d
--- /dev/null
+++ b/lib/python/qmk/commands.py
@@ -0,0 +1,127 @@
1"""Helper functions for commands.
2"""
3import os
4import sys
5import shutil
6from pathlib import Path
7
8from milc import cli
9import jsonschema
10
11from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
12from qmk.json_schema import json_load, validate
13from qmk.keyboard import keyboard_alias_definitions
14from qmk.util import maybe_exit
15from qmk.path import unix_style_path
16
17
18def find_make():
19 """Returns the correct make command for this environment.
20 """
21 make_cmd = os.environ.get('MAKE')
22
23 if not make_cmd:
24 make_cmd = 'gmake' if shutil.which('gmake') else 'make'
25
26 return make_cmd
27
28
29def get_make_parallel_args(parallel=1):
30 """Returns the arguments for running the specified number of parallel jobs.
31 """
32 parallel_args = []
33
34 if int(parallel) <= 0:
35 # 0 or -1 means -j without argument (unlimited jobs)
36 parallel_args.append('--jobs')
37 elif int(parallel) > 1:
38 parallel_args.append('--jobs=' + str(parallel))
39
40 if int(parallel) != 1:
41 # If more than 1 job is used, synchronize parallel output by target
42 parallel_args.append('--output-sync=target')
43
44 return parallel_args
45
46
47def parse_configurator_json(configurator_file):
48 """Open and parse a configurator json export
49 """
50 user_keymap = json_load(configurator_file)
51 # Validate against the jsonschema
52 try:
53 validate(user_keymap, 'qmk.keymap.v1')
54
55 except jsonschema.ValidationError as e:
56 cli.log.error(f'Invalid JSON keymap: {configurator_file} : {e.message}')
57 maybe_exit(1)
58
59 keyboard = user_keymap.get('keyboard', None)
60 aliases = keyboard_alias_definitions()
61
62 while keyboard in aliases:
63 last_keyboard = keyboard
64 keyboard = aliases[keyboard].get('target', keyboard)
65 if keyboard == last_keyboard:
66 break
67
68 user_keymap['keyboard'] = keyboard
69 return user_keymap
70
71
72def parse_env_vars(args):
73 """Common processing for cli.args.env
74 """
75 envs = {}
76 for env in args:
77 if '=' in env:
78 key, value = env.split('=', 1)
79 envs[key] = value
80 else:
81 cli.log.warning('Invalid environment variable: %s', env)
82 return envs
83
84
85def build_environment(args):
86 envs = parse_env_vars(args)
87
88 if HAS_QMK_USERSPACE:
89 envs['QMK_USERSPACE'] = unix_style_path(Path(QMK_USERSPACE).resolve())
90
91 return envs
92
93
94def in_virtualenv():
95 """Check if running inside a virtualenv.
96 Based on https://stackoverflow.com/a/1883251
97 """
98 active_prefix = getattr(sys, "base_prefix", None) or getattr(sys, "real_prefix", None) or sys.prefix
99 return active_prefix != sys.prefix
100
101
102def dump_lines(output_file, lines, quiet=True, remove_repeated_newlines=False):
103 """Handle dumping to stdout or file
104 Creates parent folders if required
105 """
106 generated = '\n'.join(lines) + '\n'
107 if remove_repeated_newlines:
108 while '\n\n\n' in generated:
109 generated = generated.replace('\n\n\n', '\n\n')
110 if output_file and output_file.name != '-':
111 output_file.parent.mkdir(parents=True, exist_ok=True)
112 if output_file.exists():
113 with open(output_file, 'r', encoding='utf-8', newline='\n') as f:
114 existing = f.read()
115 if existing == generated:
116 if not quiet:
117 cli.log.info(f'No changes to {output_file.name}.')
118 return
119 output_file.replace(output_file.parent / (output_file.name + '.bak'))
120 with open(output_file, 'w', encoding='utf-8', newline='\n') as f:
121 f.write(generated)
122 # output_file.write_text(generated, encoding='utf-8', newline='\n') # `newline` needs Python 3.10
123
124 if not quiet:
125 cli.log.info(f'Wrote {output_file.name} to {output_file}.')
126 else:
127 print(generated)
diff --git a/lib/python/qmk/comment_remover.py b/lib/python/qmk/comment_remover.py
new file mode 100644
index 0000000000..45a25257f8
--- /dev/null
+++ b/lib/python/qmk/comment_remover.py
@@ -0,0 +1,20 @@
1"""Removes C/C++ style comments from text.
2
3Gratefully adapted from https://stackoverflow.com/a/241506
4"""
5import re
6
7comment_pattern = re.compile(r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"', re.DOTALL | re.MULTILINE)
8
9
10def _comment_stripper(match):
11 """Removes C/C++ style comments from a regex match.
12 """
13 s = match.group(0)
14 return ' ' if s.startswith('/') else s
15
16
17def comment_remover(text):
18 """Remove C/C++ style comments from text.
19 """
20 return re.sub(comment_pattern, _comment_stripper, text)
diff --git a/lib/python/qmk/community_modules.py b/lib/python/qmk/community_modules.py
new file mode 100644
index 0000000000..f7e96a6b93
--- /dev/null
+++ b/lib/python/qmk/community_modules.py
@@ -0,0 +1,100 @@
1import os
2
3from pathlib import Path
4from functools import lru_cache
5
6from milc.attrdict import AttrDict
7
8from qmk.json_schema import json_load, validate, merge_ordered_dicts
9from qmk.util import truthy
10from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
11from qmk.path import under_qmk_firmware, under_qmk_userspace
12
13COMMUNITY_MODULE_JSON_FILENAME = 'qmk_module.json'
14
15
16class ModuleAPI(AttrDict):
17 def __init__(self, **kwargs):
18 super().__init__()
19 for key, value in kwargs.items():
20 self[key] = value
21
22
23@lru_cache(maxsize=1)
24def module_api_list():
25 module_definition_files = sorted(set(QMK_FIRMWARE.glob('data/constants/module_hooks/*.hjson')))
26 module_definition_jsons = [json_load(f) for f in module_definition_files]
27 module_definitions = merge_ordered_dicts(module_definition_jsons)
28 latest_module_version = module_definition_files[-1].stem
29 latest_module_version_parts = latest_module_version.split('.')
30
31 api_list = []
32 for name, mod in module_definitions.items():
33 api_list.append(ModuleAPI(
34 ret_type=mod['ret_type'],
35 name=name,
36 args=mod['args'],
37 call_params=mod.get('call_params', ''),
38 guard=mod.get('guard', None),
39 header=mod.get('header', None),
40 ))
41
42 return api_list, latest_module_version, latest_module_version_parts[0], latest_module_version_parts[1], latest_module_version_parts[2]
43
44
45def find_available_module_paths():
46 """Find all available modules.
47 """
48 search_dirs = []
49 if HAS_QMK_USERSPACE:
50 search_dirs.append(QMK_USERSPACE / 'modules')
51 search_dirs.append(QMK_FIRMWARE / 'modules')
52
53 modules = []
54 for search_dir in search_dirs:
55 for module_json_path in search_dir.rglob(COMMUNITY_MODULE_JSON_FILENAME):
56 modules.append(module_json_path.parent)
57 return modules
58
59
60def find_module_path(module):
61 """Find a module by name.
62 """
63 for module_path in find_available_module_paths():
64 # Ensure the module directory is under QMK Firmware or QMK Userspace
65 relative_path = under_qmk_firmware(module_path)
66 if not relative_path:
67 relative_path = under_qmk_userspace(module_path)
68 if not relative_path:
69 continue
70
71 lhs = str(relative_path.as_posix())[len('modules/'):]
72 rhs = str(Path(module).as_posix())
73
74 if relative_path and lhs == rhs:
75 return module_path
76 return None
77
78
79def load_module_json(module):
80 """Load a module JSON file.
81 """
82 module_path = find_module_path(module)
83 if not module_path:
84 raise FileNotFoundError(f'Module not found: {module}')
85
86 module_json = json_load(module_path / COMMUNITY_MODULE_JSON_FILENAME)
87
88 if not truthy(os.environ.get('SKIP_SCHEMA_VALIDATION'), False):
89 validate(module_json, 'qmk.community_module.v1')
90
91 module_json['module'] = module
92 module_json['module_path'] = module_path
93
94 return module_json
95
96
97def load_module_jsons(modules):
98 """Load the module JSON files, matching the specified order.
99 """
100 return list(map(load_module_json, modules))
diff --git a/lib/python/qmk/compilation_database.py b/lib/python/qmk/compilation_database.py
new file mode 100755
index 0000000000..851dc3f157
--- /dev/null
+++ b/lib/python/qmk/compilation_database.py
@@ -0,0 +1,140 @@
1"""Creates a compilation database for the given keyboard build.
2"""
3
4import json
5import os
6import re
7import shlex
8import shutil
9from functools import lru_cache
10from pathlib import Path
11from typing import Dict, Iterator, List
12
13from milc import cli
14
15from qmk.commands import find_make
16from qmk.constants import QMK_FIRMWARE
17
18
19@lru_cache(maxsize=10)
20def system_libs(binary: str) -> List[Path]:
21 """Find the system include directory that the given build tool uses.
22 """
23 cli.log.debug("searching for system library directory for binary: %s", binary)
24
25 # Actually query xxxxxx-gcc to find its include paths.
26 if binary.endswith("gcc") or binary.endswith("g++"):
27 # (TODO): Remove 'stdin' once 'input' no longer causes issues under MSYS
28 result = cli.run([binary, '-E', '-Wp,-v', '-'], capture_output=True, check=True, stdin=None, input='\n')
29 paths = []
30 for line in result.stderr.splitlines():
31 if line.startswith(" "):
32 paths.append(Path(line.strip()).resolve())
33 return paths
34
35 return list(Path(binary).resolve().parent.parent.glob("*/include")) if binary else []
36
37
38@lru_cache(maxsize=10)
39def cpu_defines(binary: str, compiler_args: str) -> List[str]:
40 cli.log.debug("gathering definitions for compilation: %s %s", binary, compiler_args)
41 if binary.endswith("gcc") or binary.endswith("g++"):
42 invocation = [binary, '-dM', '-E']
43 if binary.endswith("gcc"):
44 invocation.extend(['-x', 'c', '-std=gnu11'])
45 elif binary.endswith("g++"):
46 invocation.extend(['-x', 'c++', '-std=gnu++14'])
47 invocation.extend(shlex.split(compiler_args))
48 invocation.append('-')
49 result = cli.run(invocation, capture_output=True, check=True, stdin=None, input='\n')
50 define_args = []
51 for line in result.stdout.splitlines():
52 line_args = line.split(' ', 2)
53 if len(line_args) == 3 and line_args[0] == '#define':
54 define_args.append(f'-D{line_args[1]}={line_args[2]}')
55 elif len(line_args) == 2 and line_args[0] == '#define':
56 define_args.append(f'-D{line_args[1]}')
57
58 type_filter = re.compile(
59 r'^-D__(SIZE|INT|UINT|WINT|WCHAR|BYTE|SHRT|SIG|FLOAT|LONG|CHAR|SCHAR|DBL|FLT|LDBL|PTRDIFF|QQ|DQ|DA|HA|HQ|SA|SQ|TA|TQ|UDA|UDQ|UHA|UHQ|USQ|USA|UTQ|UTA|UQQ|UQA|ACCUM|FRACT|UACCUM|UFRACT|LACCUM|LFRACT|ULACCUM|ULFRACT|LLACCUM|LLFRACT|ULLACCUM|ULLFRACT|SACCUM|SFRACT|USACCUM|USFRACT)'
60 )
61 return list(sorted(set(filter(lambda x: not type_filter.match(x), define_args))))
62 return []
63
64
65file_re = re.compile(r'printf "Compiling: ([^"]+)')
66cmd_re = re.compile(r'LOG=\$\((.+?)&&')
67
68
69def parse_make_n(f: Iterator[str]) -> List[Dict[str, str]]:
70 """parse the output of `make -n <target>`
71
72 This function makes many assumptions about the format of your build log.
73 This happens to work right now for qmk.
74 """
75
76 state = 'start'
77 this_file = None
78 records = []
79 for line in f:
80 if state == 'start':
81 m = file_re.search(line)
82 if m:
83 this_file = m.group(1)
84 state = 'cmd'
85
86 if state == 'cmd':
87 assert this_file
88 m = cmd_re.search(line)
89 if m:
90 # we have a hit!
91 this_cmd = m.group(1)
92 args = shlex.split(this_cmd)
93 binary = shutil.which(args[0])
94 compiler_args = set(filter(lambda x: x.startswith('-m') or x.startswith('-f'), args))
95 for s in system_libs(binary):
96 args += ['-isystem', '%s' % s]
97 args.extend(cpu_defines(binary, ' '.join(shlex.quote(s) for s in compiler_args)))
98 args[0] = binary
99 records.append({"arguments": args, "directory": str(QMK_FIRMWARE.resolve()), "file": this_file})
100 state = 'start'
101
102 return records
103
104
105def write_compilation_database(keyboard: str = None, keymap: str = None, output_path: Path = QMK_FIRMWARE / 'compile_commands.json', skip_clean: bool = False, command: List[str] = None, **env_vars) -> bool:
106 # Generate the make command for a specific keyboard/keymap.
107 if not command:
108 from qmk.build_targets import KeyboardKeymapBuildTarget # Lazy load due to circular references
109 target = KeyboardKeymapBuildTarget(keyboard, keymap)
110 command = target.compile_command(dry_run=True, **env_vars)
111
112 if not command:
113 cli.log.error('You must supply both `--keyboard` and `--keymap`, or be in a directory for a keyboard or keymap.')
114 cli.echo('usage: qmk generate-compilation-database [-kb KEYBOARD] [-km KEYMAP]')
115 return False
116
117 # remove any environment variable overrides which could trip us up
118 env = os.environ.copy()
119 env.pop("MAKEFLAGS", None)
120
121 # re-use same executable as the main make invocation (might be gmake)
122 if not skip_clean:
123 clean_command = [find_make(), "clean"]
124 cli.log.info('Making clean with {fg_cyan}%s', ' '.join(clean_command))
125 cli.run(clean_command, capture_output=False, check=True, env=env)
126
127 cli.log.info('Gathering build instructions from {fg_cyan}%s', ' '.join(command))
128
129 result = cli.run(command, capture_output=True, check=True, env=env)
130 db = parse_make_n(result.stdout.splitlines())
131 if not db:
132 cli.log.error("Failed to parse output from make output:\n%s", result.stdout)
133 return False
134
135 cli.log.info("Found %s compile commands", len(db))
136
137 cli.log.info(f"Writing build database to {output_path}")
138 output_path.write_text(json.dumps(db, indent=4))
139
140 return True
diff --git a/lib/python/qmk/constants.py b/lib/python/qmk/constants.py
new file mode 100644
index 0000000000..e3e47c2bd2
--- /dev/null
+++ b/lib/python/qmk/constants.py
@@ -0,0 +1,327 @@
1"""Information that should be available to the python library.
2"""
3from os import environ
4from datetime import date
5from pathlib import Path
6
7from qmk.userspace import detect_qmk_userspace
8
9# The root of the qmk_firmware tree.
10QMK_FIRMWARE = Path.cwd()
11
12# The detected userspace tree
13QMK_USERSPACE = detect_qmk_userspace()
14
15# Whether or not we have a separate userspace directory
16HAS_QMK_USERSPACE = True if QMK_USERSPACE is not None else False
17
18# Upstream repo url
19QMK_FIRMWARE_UPSTREAM = 'qmk/qmk_firmware'
20
21# This is the number of directories under `qmk_firmware/keyboards` that will be traversed. This is currently a limitation of our make system.
22MAX_KEYBOARD_SUBFOLDERS = 5
23
24# Supported processor types
25CHIBIOS_PROCESSORS = 'cortex-m0', 'cortex-m0plus', 'cortex-m3', 'cortex-m4', 'MKL26Z64', 'MK20DX128', 'MK20DX256', 'MK64FX512', 'MK66FX1M0', 'RP2040', 'STM32F042', 'STM32F072', 'STM32F103', 'STM32F303', 'STM32F401', 'STM32F405', 'STM32F407', 'STM32F411', 'STM32F446', 'STM32G0B1', 'STM32G431', 'STM32G474', 'STM32H723', 'STM32H733', 'STM32L412', 'STM32L422', 'STM32L432', 'STM32L433', 'STM32L442', 'STM32L443', 'GD32VF103', 'WB32F3G71', 'WB32FQ95', 'AT32F415'
26LUFA_PROCESSORS = 'at90usb162', 'atmega16u2', 'atmega32u2', 'atmega16u4', 'atmega32u4', 'at90usb646', 'at90usb647', 'at90usb1286', 'at90usb1287', None
27VUSB_PROCESSORS = 'atmega32a', 'atmega328p', 'atmega328', 'attiny85'
28
29# Bootloaders of the supported processors
30MCU2BOOTLOADER = {
31 "RP2040": "rp2040",
32 "MKL26Z64": "halfkay",
33 "MK20DX128": "halfkay",
34 "MK20DX256": "halfkay",
35 "MK66FX1M0": "halfkay",
36 "STM32F042": "stm32-dfu",
37 "STM32F072": "stm32-dfu",
38 "STM32F103": "stm32duino",
39 "STM32F303": "stm32-dfu",
40 "STM32F401": "stm32-dfu",
41 "STM32F405": "stm32-dfu",
42 "STM32F407": "stm32-dfu",
43 "STM32F411": "stm32-dfu",
44 "STM32F446": "stm32-dfu",
45 "STM32G0B1": "stm32-dfu",
46 "STM32G431": "stm32-dfu",
47 "STM32G474": "stm32-dfu",
48 "STM32H723": "stm32-dfu",
49 "STM32H733": "stm32-dfu",
50 "STM32L412": "stm32-dfu",
51 "STM32L422": "stm32-dfu",
52 "STM32L432": "stm32-dfu",
53 "STM32L433": "stm32-dfu",
54 "STM32L442": "stm32-dfu",
55 "STM32L443": "stm32-dfu",
56 "GD32VF103": "gd32v-dfu",
57 "WB32F3G71": "wb32-dfu",
58 "WB32FQ95": "wb32-dfu",
59 "AT32F415": "at32-dfu",
60 "atmega16u2": "atmel-dfu",
61 "atmega32u2": "atmel-dfu",
62 "atmega16u4": "atmel-dfu",
63 "atmega32u4": "atmel-dfu",
64 "at90usb162": "atmel-dfu",
65 "at90usb646": "atmel-dfu",
66 "at90usb647": "atmel-dfu",
67 "at90usb1286": "atmel-dfu",
68 "at90usb1287": "atmel-dfu",
69 "atmega32a": "bootloadhid",
70 "atmega328p": "usbasploader",
71 "atmega328": "usbasploader",
72}
73
74# Map of legacy keycodes that can be automatically updated
75LEGACY_KEYCODES = { # Comment here is to force multiline formatting
76 'RESET': 'QK_BOOT'
77}
78
79# Map VID:PID values to bootloaders
80BOOTLOADER_VIDS_PIDS = {
81 'atmel-dfu': {
82 ("03eb", "2fef"), # ATmega16U2
83 ("03eb", "2ff0"), # ATmega32U2
84 ("03eb", "2ff3"), # ATmega16U4
85 ("03eb", "2ff4"), # ATmega32U4
86 ("03eb", "2ff9"), # AT90USB64
87 ("03eb", "2ffa"), # AT90USB162
88 ("03eb", "2ffb") # AT90USB128
89 },
90 'kiibohd': {("1c11", "b007")},
91 'stm32-dfu': {
92 ("1eaf", "0003"), # STM32duino
93 ("0483", "df11") # STM32 DFU
94 },
95 'apm32-dfu': {("314b", "0106")},
96 'gd32v-dfu': {("28e9", "0189")},
97 'wb32-dfu': {("342d", "dfa0")},
98 'at32-dfu': {("2e3c", "df11")},
99 'bootloadhid': {("16c0", "05df")},
100 'usbasploader': {("16c0", "05dc")},
101 'usbtinyisp': {("1782", "0c9f")},
102 'md-boot': {("03eb", "6124")},
103 'caterina': {
104 # pid.codes shared PID
105 ("1209", "2302"), # Keyboardio Atreus 2 Bootloader
106 # Spark Fun Electronics
107 ("1b4f", "9203"), # Pro Micro 3V3/8MHz
108 ("1b4f", "9205"), # Pro Micro 5V/16MHz
109 ("1b4f", "9207"), # LilyPad 3V3/8MHz (and some Pro Micro clones)
110 # Pololu Electronics
111 ("1ffb", "0101"), # A-Star 32U4
112 # Arduino SA
113 ("2341", "0036"), # Leonardo
114 ("2341", "0037"), # Micro
115 # Adafruit Industries LLC
116 ("239a", "000c"), # Feather 32U4
117 ("239a", "000d"), # ItsyBitsy 32U4 3V3/8MHz
118 ("239a", "000e"), # ItsyBitsy 32U4 5V/16MHz
119 # dog hunter AG
120 ("2a03", "0036"), # Leonardo
121 ("2a03", "0037") # Micro
122 },
123 'hid-bootloader': {
124 ("03eb", "2067"), # QMK HID
125 ("16c0", "0478") # PJRC halfkay
126 }
127}
128
129# Common format strings
130DATE_FORMAT = '%Y-%m-%d'
131DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S %Z'
132TIME_FORMAT = '%H:%M:%S'
133
134# Used when generating matrix locations
135COL_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijilmnopqrstuvwxyz'
136ROW_LETTERS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop'
137
138# Constants that should match their counterparts in make
139BUILD_DIR = environ.get('BUILD_DIR', '.build')
140INTERMEDIATE_OUTPUT_PREFIX = f'{BUILD_DIR}/obj_'
141
142# Headers for generated files
143GPL2_HEADER_C_LIKE = f'''\
144// Copyright {date.today().year} QMK
145// SPDX-License-Identifier: GPL-2.0-or-later
146'''
147
148GPL2_HEADER_SH_LIKE = f'''\
149# Copyright {date.today().year} QMK
150# SPDX-License-Identifier: GPL-2.0-or-later
151'''
152
153GENERATED_HEADER_C_LIKE = '''\
154/*******************************************************************************
155 88888888888 888 d8b .d888 d8b 888 d8b
156 888 888 Y8P d88P" Y8P 888 Y8P
157 888 888 888 888
158 888 88888b. 888 .d8888b 888888 888 888 .d88b. 888 .d8888b
159 888 888 "88b 888 88K 888 888 888 d8P Y8b 888 88K
160 888 888 888 888 "Y8888b. 888 888 888 88888888 888 "Y8888b.
161 888 888 888 888 X88 888 888 888 Y8b. 888 X88
162 888 888 888 888 88888P' 888 888 888 "Y8888 888 88888P'
163 888 888
164 888 888
165 888 888
166 .d88b. .d88b. 88888b. .d88b. 888d888 8888b. 888888 .d88b. .d88888
167 d88P"88b d8P Y8b 888 "88b d8P Y8b 888P" "88b 888 d8P Y8b d88" 888
168 888 888 88888888 888 888 88888888 888 .d888888 888 88888888 888 888
169 Y88b 888 Y8b. 888 888 Y8b. 888 888 888 Y88b. Y8b. Y88b 888
170 "Y88888 "Y8888 888 888 "Y8888 888 "Y888888 "Y888 "Y8888 "Y88888
171 888
172 Y8b d88P
173 "Y88P"
174*******************************************************************************/
175'''
176
177GENERATED_HEADER_SH_LIKE = '''\
178################################################################################
179#
180# 88888888888 888 d8b .d888 d8b 888 d8b
181# 888 888 Y8P d88P" Y8P 888 Y8P
182# 888 888 888 888
183# 888 88888b. 888 .d8888b 888888 888 888 .d88b. 888 .d8888b
184# 888 888 "88b 888 88K 888 888 888 d8P Y8b 888 88K
185# 888 888 888 888 "Y8888b. 888 888 888 88888888 888 "Y8888b.
186# 888 888 888 888 X88 888 888 888 Y8b. 888 X88
187# 888 888 888 888 88888P' 888 888 888 "Y8888 888 88888P'
188#
189# 888 888
190# 888 888
191# 888 888
192# .d88b. .d88b. 88888b. .d88b. 888d888 8888b. 888888 .d88b. .d88888
193# d88P"88b d8P Y8b 888 "88b d8P Y8b 888P" "88b 888 d8P Y8b d88" 888
194# 888 888 88888888 888 888 88888888 888 .d888888 888 88888888 888 888
195# Y88b 888 Y8b. 888 888 Y8b. 888 888 888 Y88b. Y8b. Y88b 888
196# "Y88888 "Y8888 888 888 "Y8888 888 "Y888888 "Y888 "Y8888 "Y88888
197# 888
198# Y8b d88P
199# "Y88P"
200#
201################################################################################
202'''
203
204LICENSE_TEXTS = [
205 (
206 'GPL-2.0-or-later', [
207 """\
208 This program is free software; you can redistribute it and/or
209 modify it under the terms of the GNU General Public License
210 as published by the Free Software Foundation; either version 2
211 of the License, or (at your option) any later version.
212 """, """\
213 This program is free software; you can redistribute it and/or
214 modify it under the terms of the GNU General Public License
215 as published by the Free Software Foundation; either version 2
216 of the License, or any later version.
217 """
218 ]
219 ),
220 ('GPL-2.0-only', ["""\
221 This program is free software; you can redistribute it and/or
222 modify it under the terms of the GNU General Public License as
223 published by the Free Software Foundation; version 2.
224 """]),
225 (
226 'GPL-3.0-or-later', [
227 """\
228 This program is free software: you can redistribute it and/or
229 modify it under the terms of the GNU General Public License as
230 published by the Free Software Foundation, either version 3 of
231 the License, or (at your option) any later version.
232 """, """\
233 This program is free software: you can redistribute it and/or
234 modify it under the terms of the GNU General Public License as
235 published by the Free Software Foundation, either version 3 of
236 the License, or any later version.
237 """
238 ]
239 ),
240 ('GPL-3.0-only', ["""\
241 This program is free software: you can redistribute it and/or
242 modify it under the terms of the GNU General Public License as
243 published by the Free Software Foundation, version 3.
244 """]),
245 (
246 'LGPL-2.1-or-later', [
247 """\
248 This program is free software; you can redistribute it and/or
249 modify it under the terms of the GNU Lesser General Public License
250 as published by the Free Software Foundation; either version 2.1
251 of the License, or (at your option) any later version.
252 """, """\
253 This program is free software; you can redistribute it and/or
254 modify it under the terms of the GNU Lesser General Public License
255 as published by the Free Software Foundation; either version 2.1
256 of the License, or any later version.
257 """, """\
258 This library is free software; you can redistribute it and/or
259 modify it under the terms of the GNU Lesser General Public License
260 as published by the Free Software Foundation; either version 2.1
261 of the License, or (at your option) any later version.
262 """, """\
263 This library is free software; you can redistribute it and/or
264 modify it under the terms of the GNU Lesser General Public License
265 as published by the Free Software Foundation; either version 2.1
266 of the License, or any later version.
267 """
268 ]
269 ),
270 (
271 'LGPL-2.1-only', [
272 """\
273 This program is free software; you can redistribute it and/or
274 modify it under the terms of the GNU Lesser General Public License as
275 published by the Free Software Foundation; version 2.1.
276 """, """\
277 This library is free software; you can redistribute it and/or
278 modify it under the terms of the GNU Lesser General Public License as
279 published by the Free Software Foundation; version 2.1.
280 """
281 ]
282 ),
283 (
284 'LGPL-3.0-or-later', [
285 """\
286 This program is free software; you can redistribute it and/or
287 modify it under the terms of the GNU Lesser General Public License
288 as published by the Free Software Foundation; either version 3
289 of the License, or (at your option) any later version.
290 """, """\
291 This program is free software; you can redistribute it and/or
292 modify it under the terms of the GNU Lesser General Public License
293 as published by the Free Software Foundation; either version 3
294 of the License, or any later version.
295 """, """\
296 This library is free software; you can redistribute it and/or
297 modify it under the terms of the GNU Lesser General Public License
298 as published by the Free Software Foundation; either version 3
299 of the License, or (at your option) any later version.
300 """, """\
301 This library is free software; you can redistribute it and/or
302 modify it under the terms of the GNU Lesser General Public License
303 as published by the Free Software Foundation; either version 3
304 of the License, or any later version.
305 """
306 ]
307 ),
308 (
309 'LGPL-3.0-only', [
310 """\
311 This program is free software; you can redistribute it and/or
312 modify it under the terms of the GNU Lesser General Public License as
313 published by the Free Software Foundation; version 3.
314 """, """\
315 This library is free software; you can redistribute it and/or
316 modify it under the terms of the GNU Lesser General Public License as
317 published by the Free Software Foundation; version 3.
318 """
319 ]
320 ),
321 ('Apache-2.0', ["""\
322 Licensed under the Apache License, Version 2.0 (the "License");
323 you may not use this file except in compliance with the License.
324 """]),
325]
326
327JOYSTICK_AXES = ['x', 'y', 'z', 'rx', 'ry', 'rz']
diff --git a/lib/python/qmk/converter.py b/lib/python/qmk/converter.py
new file mode 100644
index 0000000000..bbd3531317
--- /dev/null
+++ b/lib/python/qmk/converter.py
@@ -0,0 +1,33 @@
1"""Functions to convert to and from QMK formats
2"""
3from collections import OrderedDict
4
5
6def kle2qmk(kle):
7 """Convert a KLE layout to QMK's layout format.
8 """
9 layout = []
10
11 for row in kle:
12 for key in row:
13 if key['decal']:
14 continue
15
16 qmk_key = OrderedDict(
17 label="",
18 x=key['column'],
19 y=key['row'],
20 )
21
22 if key['width'] != 1:
23 qmk_key['w'] = key['width']
24 if key['height'] != 1:
25 qmk_key['h'] = key['height']
26 if 'name' in key and key['name']:
27 qmk_key['label'] = key['name'].split('\n', 1)[0]
28 else:
29 del (qmk_key['label'])
30
31 layout.append(qmk_key)
32
33 return layout
diff --git a/lib/python/qmk/datetime.py b/lib/python/qmk/datetime.py
new file mode 100644
index 0000000000..4bffcc6217
--- /dev/null
+++ b/lib/python/qmk/datetime.py
@@ -0,0 +1,29 @@
1"""Functions to work with dates and times in a uniform way.
2
3The results of these functions are cached for 5 seconds to provide uniform time strings across short running processes. Long running processes that need more precise timekeeping should not use these functions.
4"""
5from time import gmtime, strftime
6
7from qmk.constants import DATE_FORMAT, DATETIME_FORMAT, TIME_FORMAT
8from qmk.decorators import lru_cache
9
10
11@lru_cache(timeout=5)
12def current_date():
13 """Returns the current time in UTZ as a formatted string.
14 """
15 return strftime(DATE_FORMAT, gmtime())
16
17
18@lru_cache(timeout=5)
19def current_datetime():
20 """Returns the current time in UTZ as a formatted string.
21 """
22 return strftime(DATETIME_FORMAT, gmtime())
23
24
25@lru_cache(timeout=5)
26def current_time():
27 """Returns the current time in UTZ as a formatted string.
28 """
29 return strftime(TIME_FORMAT, gmtime())
diff --git a/lib/python/qmk/decorators.py b/lib/python/qmk/decorators.py
new file mode 100644
index 0000000000..0cad55a483
--- /dev/null
+++ b/lib/python/qmk/decorators.py
@@ -0,0 +1,102 @@
1"""Helpful decorators that subcommands can use.
2"""
3import functools
4from time import monotonic
5
6from milc import cli
7
8from qmk.keyboard import find_keyboard_from_dir, keyboard_folder
9from qmk.keymap import find_keymap_from_dir
10
11
12def _get_subcommand_name():
13 """Handle missing cli.subcommand_name on older versions of milc
14 """
15 try:
16 return cli.subcommand_name
17 except AttributeError:
18 return cli._subcommand.__name__
19
20
21def automagic_keyboard(func):
22 """Sets `cli.config.<subcommand>.keyboard` based on environment.
23
24 This will rewrite cli.config.<subcommand>.keyboard if the user did not pass `--keyboard` and the directory they are currently in is a keyboard or keymap directory.
25 """
26 @functools.wraps(func)
27 def wrapper(*args, **kwargs):
28 cmd = _get_subcommand_name()
29
30 # TODO: Workaround for if config file contains "old" keyboard name
31 # Potential long-term fix needs to be within global cli or milc
32 if cli.config_source[cmd]['keyboard'] == 'config_file':
33 cli.config[cmd]['keyboard'] = keyboard_folder(cli.config[cmd]['keyboard'])
34
35 # Ensure that `--keyboard` was not passed and CWD is under `qmk_firmware/keyboards`
36 if cli.config_source[cmd]['keyboard'] != 'argument':
37 keyboard = find_keyboard_from_dir()
38
39 if keyboard:
40 cli.config[cmd]['keyboard'] = keyboard
41 cli.config_source[cmd]['keyboard'] = 'keyboard_directory'
42
43 return func(*args, **kwargs)
44
45 return wrapper
46
47
48def automagic_keymap(func):
49 """Sets `cli.config.<subcommand>.keymap` based on environment.
50
51 This will rewrite cli.config.<subcommand>.keymap if the user did not pass `--keymap` and the directory they are currently in is a keymap, layout, or user directory.
52 """
53 @functools.wraps(func)
54 def wrapper(*args, **kwargs):
55 cmd = _get_subcommand_name()
56
57 # Ensure that `--keymap` was not passed and that we're under `qmk_firmware`
58 if cli.config_source[cmd]['keymap'] != 'argument':
59 keymap_name, keymap_type = find_keymap_from_dir()
60
61 if keymap_name:
62 cli.config[cmd]['keymap'] = keymap_name
63 cli.config_source[cmd]['keymap'] = keymap_type
64
65 return func(*args, **kwargs)
66
67 return wrapper
68
69
70def lru_cache(timeout=10, maxsize=128, typed=False):
71 """Least Recently Used Cache- cache the result of a function.
72
73 Args:
74
75 timeout
76 How many seconds to cache results for.
77
78 maxsize
79 The maximum size of the cache in bytes
80
81 typed
82 When `True` argument types will be taken into consideration, for example `3` and `3.0` will be treated as different keys.
83 """
84 def wrapper_cache(func):
85 func = functools.lru_cache(maxsize=maxsize, typed=typed)(func)
86 func.expiration = monotonic() + timeout
87
88 @functools.wraps(func)
89 def wrapped_func(*args, **kwargs):
90 if monotonic() >= func.expiration:
91 func.expiration = monotonic() + timeout
92
93 func.cache_clear()
94
95 return func(*args, **kwargs)
96
97 wrapped_func.cache_info = func.cache_info
98 wrapped_func.cache_clear = func.cache_clear
99
100 return wrapped_func
101
102 return wrapper_cache
diff --git a/lib/python/qmk/docs.py b/lib/python/qmk/docs.py
new file mode 100644
index 0000000000..75d2d60bda
--- /dev/null
+++ b/lib/python/qmk/docs.py
@@ -0,0 +1,61 @@
1"""Handlers for the QMK documentation generator (docusaurus).
2"""
3import shutil
4from pathlib import Path
5from subprocess import DEVNULL
6from os import chdir, environ, makedirs, pathsep
7from milc import cli
8
9from qmk.constants import QMK_FIRMWARE
10
11DOCS_PATH = QMK_FIRMWARE / 'docs'
12BUILDDEFS_PATH = QMK_FIRMWARE / 'builddefs' / 'docsgen'
13BUILD_PATH = QMK_FIRMWARE / '.build'
14CACHE_PATH = BUILD_PATH / 'cache'
15NODE_MODULES_PATH = BUILD_PATH / 'node_modules'
16BUILD_DOCS_PATH = BUILD_PATH / 'docs'
17DOXYGEN_PATH = BUILD_DOCS_PATH / 'static' / 'doxygen'
18
19
20def run_docs_command(verb, cmd_args=None):
21 environ['PATH'] += pathsep + str(NODE_MODULES_PATH / '.bin')
22
23 args = {'capture_output': False, 'check': True}
24 docs_env = environ.copy()
25 if cli.config.general.verbose:
26 docs_env['DEBUG'] = 'vitepress:*,vite:*'
27 args['env'] = docs_env
28
29 arg_list = ['yarn', verb]
30 if cmd_args:
31 arg_list.extend(cmd_args)
32
33 chdir(BUILDDEFS_PATH)
34 cli.run(arg_list, **args)
35
36
37def prepare_docs_build_area(is_production):
38 if is_production:
39 # Set up a symlink for docs to be inside builddefs -- vitepress can't handle source files in parent directories
40 try:
41 docs_link = Path(BUILDDEFS_PATH / 'docs')
42 if not docs_link.exists():
43 docs_link.symlink_to(DOCS_PATH)
44 except NotImplementedError:
45 cli.log.error('Symlinks are not supported on this platform.')
46 return False
47
48 if BUILD_DOCS_PATH.exists():
49 shutil.rmtree(BUILD_DOCS_PATH)
50
51 # When not verbose we want to hide all output
52 args = {'capture_output': False if cli.config.general.verbose else True, 'check': True, 'stdin': DEVNULL}
53
54 makedirs(DOXYGEN_PATH)
55 cli.log.info('Generating doxygen docs at %s', DOXYGEN_PATH)
56 cli.run(['doxygen', 'Doxyfile'], **args)
57
58 cli.log.info('Installing vitepress dependencies')
59 run_docs_command('install')
60
61 return True
diff --git a/lib/python/qmk/errors.py b/lib/python/qmk/errors.py
new file mode 100644
index 0000000000..1317687821
--- /dev/null
+++ b/lib/python/qmk/errors.py
@@ -0,0 +1,12 @@
1class NoSuchKeyboardError(Exception):
2 """Raised when we can't find a keyboard/keymap directory.
3 """
4 def __init__(self, message):
5 self.message = message
6
7
8class CppError(Exception):
9 """Raised when 'cpp' cannot process a file.
10 """
11 def __init__(self, message):
12 self.message = message
diff --git a/lib/python/qmk/flashers.py b/lib/python/qmk/flashers.py
new file mode 100644
index 0000000000..6b52f4d35a
--- /dev/null
+++ b/lib/python/qmk/flashers.py
@@ -0,0 +1,248 @@
1import platform
2import shutil
3import time
4import os
5import signal
6
7import usb.core
8
9from qmk.constants import BOOTLOADER_VIDS_PIDS
10from milc import cli
11
12# yapf: disable
13_PID_TO_MCU = {
14 '2fef': 'atmega16u2',
15 '2ff0': 'atmega32u2',
16 '2ff3': 'atmega16u4',
17 '2ff4': 'atmega32u4',
18 '2ff9': 'at90usb64',
19 '2ffa': 'at90usb162',
20 '2ffb': 'at90usb128'
21}
22
23AVRDUDE_MCU = {
24 'atmega32a': 'm32',
25 'atmega328p': 'm328p',
26 'atmega328': 'm328',
27}
28# yapf: enable
29
30
31class DelayedKeyboardInterrupt:
32 # Custom interrupt handler to delay the processing of Ctrl-C
33 # https://stackoverflow.com/a/21919644
34 def __enter__(self):
35 self.signal_received = False
36 self.old_handler = signal.signal(signal.SIGINT, self.handler)
37
38 def handler(self, sig, frame):
39 self.signal_received = (sig, frame)
40
41 def __exit__(self, type, value, traceback):
42 signal.signal(signal.SIGINT, self.old_handler)
43 if self.signal_received:
44 self.old_handler(*self.signal_received)
45
46
47# TODO: Make this more generic, so cli/doctor/check.py and flashers.py can share the code
48def _check_dfu_programmer_version():
49 # Return True if version is higher than 0.7.0: supports '--force'
50 check = cli.run(['dfu-programmer', '--version'], combined_output=True, timeout=5)
51 first_line = check.stdout.split('\n')[0]
52 version_number = first_line.split()[1]
53 maj, min_, bug = version_number.split('.')
54 if int(maj) >= 0 and int(min_) >= 7:
55 return True
56 else:
57 return False
58
59
60def _find_usb_device(vid_hex, pid_hex):
61 # WSL doesnt have access to USB - use powershell instead...?
62 if 'microsoft' in platform.uname().release.lower():
63 ret = cli.run(['powershell.exe', '-command', 'Get-PnpDevice -PresentOnly | Select-Object -Property InstanceId'])
64 if f'USB\\VID_{vid_hex:04X}&PID_{pid_hex:04X}' in ret.stdout:
65 return (vid_hex, pid_hex)
66 else:
67 with DelayedKeyboardInterrupt():
68 # PyUSB does not like to be interrupted by Ctrl-C
69 # therefore we catch the interrupt with a custom handler
70 # and only process it once pyusb finished
71 return usb.core.find(idVendor=vid_hex, idProduct=pid_hex)
72
73
74def _find_uf2_devices():
75 """Delegate to uf2conv.py as VID:PID pairs can potentially fluctuate more than other bootloaders
76 """
77 return cli.run(['util/uf2conv.py', '--list']).stdout.splitlines()
78
79
80def _find_bootloader():
81 # To avoid running forever in the background, only look for bootloaders for 10min
82 start_time = time.time()
83 while time.time() - start_time < 600:
84 for bl in BOOTLOADER_VIDS_PIDS:
85 for vid, pid in BOOTLOADER_VIDS_PIDS[bl]:
86 vid_hex = int(f'0x{vid}', 0)
87 pid_hex = int(f'0x{pid}', 0)
88 dev = _find_usb_device(vid_hex, pid_hex)
89 if dev:
90 if bl == 'atmel-dfu':
91 details = _PID_TO_MCU[pid]
92 elif bl == 'caterina':
93 details = (vid_hex, pid_hex)
94 elif bl == 'hid-bootloader':
95 if vid == '16c0' and pid == '0478':
96 details = 'halfkay'
97 else:
98 details = 'qmk-hid'
99 elif bl in {'apm32-dfu', 'at32-dfu', 'gd32v-dfu', 'kiibohd', 'stm32-dfu'}:
100 details = (vid, pid)
101 else:
102 details = None
103 return (bl, details)
104 if _find_uf2_devices():
105 return ('_uf2_compatible_', None)
106 time.sleep(0.1)
107 return (None, None)
108
109
110def _find_serial_port(vid, pid):
111 if 'windows' in cli.platform.lower():
112 from serial.tools.list_ports_windows import comports
113 platform = 'windows'
114 else:
115 from serial.tools.list_ports_posix import comports
116 platform = 'posix'
117
118 start_time = time.time()
119 # Caterina times out after 8 seconds
120 while time.time() - start_time < 8:
121 for port in comports():
122 port, desc, hwid = port
123 if f'{vid:04x}:{pid:04x}' in hwid.casefold():
124 if platform == 'windows':
125 time.sleep(1)
126 return port
127 else:
128 start_time = time.time()
129 # Wait until the port becomes writable before returning
130 while time.time() - start_time < 8:
131 if os.access(port, os.W_OK):
132 return port
133 else:
134 time.sleep(0.5)
135 return None
136 return None
137
138
139def _flash_caterina(details, file):
140 port = _find_serial_port(details[0], details[1])
141 if port:
142 cli.run(['avrdude', '-p', 'atmega32u4', '-c', 'avr109', '-U', f'flash:w:{file}:i', '-P', port], capture_output=False)
143 return False
144 else:
145 return True
146
147
148def _flash_atmel_dfu(mcu, file):
149 force = '--force' if _check_dfu_programmer_version() else ''
150 cli.run(['dfu-programmer', mcu, 'erase', force], capture_output=False)
151 cli.run(['dfu-programmer', mcu, 'flash', force, file], capture_output=False)
152 cli.run(['dfu-programmer', mcu, 'reset'], capture_output=False)
153
154
155def _flash_hid_bootloader(mcu, details, file):
156 cmd = None
157 if details == 'halfkay':
158 if shutil.which('teensy_loader_cli'):
159 cmd = 'teensy_loader_cli'
160 elif shutil.which('teensy-loader-cli'):
161 cmd = 'teensy-loader-cli'
162
163 # Use 'hid_bootloader_cli' for QMK HID and as a fallback for HalfKay
164 if not cmd:
165 if shutil.which('hid_bootloader_cli'):
166 cmd = 'hid_bootloader_cli'
167 else:
168 return True
169
170 cli.run([cmd, f'-mmcu={mcu}', '-w', '-v', file], capture_output=False)
171
172
173def _flash_dfu_util(details, file):
174 # STM32duino
175 if details[0] == '1eaf' and details[1] == '0003':
176 cli.run(['dfu-util', '-a', '2', '-d', f'{details[0]}:{details[1]}', '-R', '-D', file], capture_output=False)
177 # kiibohd
178 elif details[0] == '1c11' and details[1] == 'b007':
179 cli.run(['dfu-util', '-a', '0', '-d', f'{details[0]}:{details[1]}', '-D', file], capture_output=False)
180 # STM32, APM32, AT32, or GD32V DFU
181 else:
182 cli.run(['dfu-util', '-a', '0', '-d', f'{details[0]}:{details[1]}', '-s', '0x08000000:leave', '-D', file], capture_output=False)
183
184
185def _flash_wb32_dfu_updater(file):
186 if shutil.which('wb32-dfu-updater_cli'):
187 cmd = 'wb32-dfu-updater_cli'
188 else:
189 return True
190
191 cli.run([cmd, '-t', '-s', '0x08000000', '-D', file], capture_output=False)
192
193
194def _flash_isp(mcu, programmer, file):
195 programmer = 'usbasp' if programmer == 'usbasploader' else 'usbtiny'
196 # Check if the provided mcu has an avrdude-specific name, otherwise pass on what the user provided
197 mcu = AVRDUDE_MCU.get(mcu, mcu)
198 cli.run(['avrdude', '-p', mcu, '-c', programmer, '-U', f'flash:w:{file}:i'], capture_output=False)
199
200
201def _flash_mdloader(file):
202 cli.run(['mdloader', '--first', '--download', file, '--restart'], capture_output=False)
203
204
205def _flash_uf2(file):
206 output = cli.run(['util/uf2conv.py', '--info', file]).stdout
207 if 'UF2 File' not in output:
208 return True
209
210 cli.run(['util/uf2conv.py', '--deploy', file], capture_output=False)
211
212
213def flasher(mcu, file):
214 # Avoid "expected string or bytes-like object, got 'WindowsPath" issues
215 file = file.as_posix()
216 bl, details = _find_bootloader()
217 # Add a small sleep to avoid race conditions
218 time.sleep(1)
219 if bl == 'atmel-dfu':
220 _flash_atmel_dfu(details, file)
221 elif bl == 'caterina':
222 if _flash_caterina(details, file):
223 return (True, "The Caterina bootloader was found but is not writable. Check 'qmk doctor' output for advice.")
224 elif bl == 'hid-bootloader':
225 if mcu:
226 if _flash_hid_bootloader(mcu, details, file):
227 return (True, "Please make sure 'teensy_loader_cli' or 'hid_bootloader_cli' is available on your system.")
228 else:
229 return (True, "Specifying the MCU with '-m' is necessary for HalfKay/HID bootloaders!")
230 elif bl in {'apm32-dfu', 'at32-dfu', 'gd32v-dfu', 'kiibohd', 'stm32-dfu'}:
231 _flash_dfu_util(details, file)
232 elif bl == 'wb32-dfu':
233 if _flash_wb32_dfu_updater(file):
234 return (True, "Please make sure 'wb32-dfu-updater_cli' is available on your system.")
235 elif bl == 'usbasploader' or bl == 'usbtinyisp':
236 if mcu:
237 _flash_isp(mcu, bl, file)
238 else:
239 return (True, "Specifying the MCU with '-m' is necessary for ISP flashing!")
240 elif bl == 'md-boot':
241 _flash_mdloader(file)
242 elif bl == '_uf2_compatible_':
243 if _flash_uf2(file):
244 return (True, "Flashing only supports uf2 format files.")
245 else:
246 return (True, "Known bootloader found but flashing not currently supported!")
247
248 return (False, None)
diff --git a/lib/python/qmk/git.py b/lib/python/qmk/git.py
new file mode 100644
index 0000000000..9d567475d8
--- /dev/null
+++ b/lib/python/qmk/git.py
@@ -0,0 +1,146 @@
1"""Functions for working with the QMK repo.
2"""
3from subprocess import DEVNULL
4from pathlib import Path
5
6from milc import cli
7
8from qmk.constants import QMK_FIRMWARE
9
10
11def git_get_version(repo_dir='.', check_dir='.'):
12 """Returns the current git version for a repo, or None.
13 """
14 git_describe_cmd = ['git', 'describe', '--abbrev=6', '--dirty', '--always', '--tags']
15
16 if repo_dir != '.':
17 repo_dir = Path('lib') / repo_dir
18
19 if check_dir != '.':
20 check_dir = repo_dir / check_dir
21
22 if Path(check_dir).exists():
23 git_describe = cli.run(git_describe_cmd, stdin=DEVNULL, cwd=repo_dir)
24
25 if git_describe.returncode == 0:
26 return git_describe.stdout.strip()
27
28 else:
29 cli.log.warning(f'"{" ".join(git_describe_cmd)}" returned error code {git_describe.returncode}')
30 print(git_describe.stderr)
31 return None
32
33 return None
34
35
36def git_get_username():
37 """Retrieves user's username from Git config, if set.
38 """
39 git_username = cli.run(['git', 'config', '--get', 'user.name'])
40
41 if git_username.returncode == 0 and git_username.stdout:
42 return git_username.stdout.strip()
43
44
45def git_get_branch():
46 """Returns the current branch for a repo, or None.
47 """
48 git_branch = cli.run(['git', 'branch', '--show-current'])
49 if not git_branch.returncode != 0 or not git_branch.stdout:
50 # Workaround for Git pre-2.22
51 git_branch = cli.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
52
53 if git_branch.returncode == 0:
54 return git_branch.stdout.strip()
55
56
57def git_get_tag():
58 """Returns the current tag for a repo, or None.
59 """
60 git_tag = cli.run(['git', 'describe', '--abbrev=0', '--tags'])
61 if git_tag.returncode == 0:
62 return git_tag.stdout.strip()
63
64
65def git_get_last_log_entry(branch_name):
66 """Retrieves the last log entry for the branch being worked on.
67 """
68 git_lastlog = cli.run(['git', '--no-pager', 'log', '--pretty=format:%ad (%h) -- %s', '--date=iso', '-n1', branch_name])
69
70 if git_lastlog.returncode == 0 and git_lastlog.stdout:
71 return git_lastlog.stdout.strip()
72
73
74def git_get_common_ancestor(branch_a, branch_b):
75 """Retrieves the common ancestor between for the two supplied branches.
76 """
77 git_merge_base = cli.run(['git', 'merge-base', branch_a, branch_b])
78 git_branchpoint_log = cli.run(['git', '--no-pager', 'log', '--pretty=format:%ad (%h) -- %s', '--date=iso', '-n1', git_merge_base.stdout.strip()])
79
80 if git_branchpoint_log.returncode == 0 and git_branchpoint_log.stdout:
81 return git_branchpoint_log.stdout.strip()
82
83
84def git_get_remotes():
85 """Returns the current remotes for a repo.
86 """
87 remotes = {}
88
89 git_remote_show_cmd = ['git', 'remote', 'show']
90 git_remote_get_cmd = ['git', 'remote', 'get-url']
91
92 git_remote_show = cli.run(git_remote_show_cmd)
93 if git_remote_show.returncode == 0:
94 for name in git_remote_show.stdout.splitlines():
95 git_remote_name = cli.run([*git_remote_get_cmd, name])
96 remotes[name.strip()] = {"url": git_remote_name.stdout.strip()}
97
98 return remotes
99
100
101def git_is_dirty():
102 """Returns 1 if repo is dirty, or 0 if clean
103 """
104 git_diff_staged_cmd = ['git', 'diff', '--quiet']
105 git_diff_unstaged_cmd = [*git_diff_staged_cmd, '--cached']
106
107 unstaged = cli.run(git_diff_staged_cmd)
108 staged = cli.run(git_diff_unstaged_cmd)
109
110 return unstaged.returncode != 0 or staged.returncode != 0
111
112
113def git_check_repo():
114 """Checks that the .git directory exists inside QMK_HOME.
115
116 This is a decent enough indicator that the qmk_firmware directory is a
117 proper Git repository, rather than a .zip download from GitHub.
118 """
119 dot_git_dir = QMK_FIRMWARE / '.git'
120
121 return dot_git_dir.is_dir()
122
123
124def git_check_deviation(active_branch):
125 """Return True if branch has custom commits
126 """
127 cli.run(['git', 'fetch', 'upstream', active_branch])
128 deviations = cli.run(['git', '--no-pager', 'log', f'upstream/{active_branch}...{active_branch}'])
129 return bool(deviations.returncode)
130
131
132def git_get_ignored_files(check_dir='.'):
133 """Return a list of files that would be captured by the current .gitignore
134 """
135 invalid = cli.run(['git', 'ls-files', '-c', '-o', '-i', '--exclude-from=.gitignore', check_dir])
136 if invalid.returncode != 0:
137 return []
138 return invalid.stdout.strip().splitlines()
139
140
141def git_get_qmk_hash():
142 output = cli.run(['git', 'rev-parse', '--short', 'HEAD'])
143 if output.returncode != 0:
144 return None
145
146 return output.stdout.strip()
diff --git a/lib/python/qmk/importers.py b/lib/python/qmk/importers.py
new file mode 100644
index 0000000000..3e7f305a43
--- /dev/null
+++ b/lib/python/qmk/importers.py
@@ -0,0 +1,201 @@
1from dotty_dict import dotty
2from datetime import date
3from pathlib import Path
4import json
5
6from qmk.git import git_get_username
7from qmk.json_schema import validate
8from qmk.path import keyboard, keymaps
9from qmk.constants import MCU2BOOTLOADER, LEGACY_KEYCODES
10from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder
11from qmk.json_schema import deep_update, json_load
12
13TEMPLATE = Path('data/templates/keyboard/')
14
15
16def replace_placeholders(src, dest, tokens):
17 """Replaces the given placeholders in each template file.
18 """
19 content = src.read_text()
20 for key, value in tokens.items():
21 content = content.replace(f'%{key}%', value)
22
23 dest.write_text(content)
24
25
26def _gen_dummy_keymap(name, info_data):
27 # Pick the first layout macro and just dump in KC_NOs or something?
28 (layout_name, layout_data), *_ = info_data["layouts"].items()
29 layout_length = len(layout_data["layout"])
30
31 keymap_data = {
32 "keyboard": name,
33 "layout": layout_name,
34 "layers": [["KC_NO" for _ in range(0, layout_length)]],
35 }
36
37 return keymap_data
38
39
40def _extract_kbfirmware_layout(kbf_data):
41 layout = []
42 for key in kbf_data['keyboard.keys']:
43 item = {
44 'matrix': [key['row'], key['col']],
45 'x': key['state']['x'],
46 'y': key['state']['y'],
47 }
48 if key['state']['w'] != 1:
49 item['w'] = key['state']['w']
50 if key['state']['h'] != 1:
51 item['h'] = key['state']['h']
52 layout.append(item)
53
54 return layout
55
56
57def _extract_kbfirmware_keymap(kbf_data):
58 keymap_data = {
59 'keyboard': kbf_data['keyboard.settings.name'].lower(),
60 'layout': 'LAYOUT',
61 'layers': [],
62 }
63
64 for i in range(15):
65 layer = []
66 for key in kbf_data['keyboard.keys']:
67 keycode = key['keycodes'][i]['id']
68 keycode = LEGACY_KEYCODES.get(keycode, keycode)
69 if '()' in keycode:
70 fields = key['keycodes'][i]['fields']
71 keycode = f'{keycode.split(")")[0]}{",".join(map(str, fields))})'
72 layer.append(keycode)
73 if set(layer) == {'KC_TRNS'}:
74 break
75 keymap_data['layers'].append(layer)
76
77 return keymap_data
78
79
80def import_keymap(keymap_data):
81 # Validate to ensure we don't have to deal with bad data - handles stdin/file
82 validate(keymap_data, 'qmk.keymap.v1')
83
84 kb_name = keymap_data['keyboard']
85 km_name = keymap_data['keymap']
86
87 km_folder = keymaps(kb_name)[0] / km_name
88 keyboard_keymap = km_folder / 'keymap.json'
89
90 # This is the deepest folder in the expected tree
91 keyboard_keymap.parent.mkdir(parents=True, exist_ok=True)
92
93 # Dump out all those lovely files
94 keyboard_keymap.write_text(json.dumps(keymap_data, cls=KeymapJSONEncoder, sort_keys=True))
95
96 return (kb_name, km_name)
97
98
99def import_keyboard(info_data, keymap_data=None):
100 # Validate to ensure we don't have to deal with bad data - handles stdin/file
101 validate(info_data, 'qmk.api.keyboard.v1')
102
103 # And validate some more as everything is optional
104 if not all(key in info_data for key in ['keyboard_name', 'layouts']):
105 raise ValueError('invalid json config')
106
107 kb_name = info_data['keyboard_name']
108
109 # bail
110 kb_folder = keyboard(kb_name)
111 if kb_folder.exists():
112 raise ValueError(f'Keyboard {{fg_cyan}}{kb_name}{{fg_reset}} already exists! Please choose a different name.')
113
114 if not keymap_data:
115 # TODO: if supports community then grab that instead
116 keymap_data = _gen_dummy_keymap(kb_name, info_data)
117
118 keyboard_json = kb_folder / 'keyboard.json'
119 keyboard_keymap = kb_folder / 'keymaps' / 'default' / 'keymap.json'
120
121 # begin with making the deepest folder in the tree
122 keyboard_keymap.parent.mkdir(parents=True, exist_ok=True)
123
124 user_name = git_get_username()
125 if not user_name:
126 user_name = 'TODO'
127
128 tokens = { # Comment here is to force multiline formatting
129 'YEAR': str(date.today().year),
130 'KEYBOARD': kb_name,
131 'USER_NAME': user_name,
132 'REAL_NAME': user_name,
133 }
134
135 # Dump out all those lovely files
136 for file in list(TEMPLATE.iterdir()):
137 replace_placeholders(file, kb_folder / file.name, tokens)
138
139 temp = json_load(keyboard_json)
140 deep_update(temp, info_data)
141
142 keyboard_json.write_text(json.dumps(temp, cls=InfoJSONEncoder, sort_keys=True))
143 keyboard_keymap.write_text(json.dumps(keymap_data, cls=KeymapJSONEncoder, sort_keys=True))
144
145 return kb_name
146
147
148def import_kbfirmware(kbfirmware_data):
149 kbf_data = dotty(kbfirmware_data)
150
151 diode_direction = ["COL2ROW", "ROW2COL"][kbf_data['keyboard.settings.diodeDirection']]
152 mcu = ["atmega32u2", "atmega32u4", "at90usb1286"][kbf_data['keyboard.controller']]
153 bootloader = MCU2BOOTLOADER.get(mcu, "custom")
154
155 layout = _extract_kbfirmware_layout(kbf_data)
156 keymap_data = _extract_kbfirmware_keymap(kbf_data)
157
158 # convert to d/d info.json
159 info_data = dotty({
160 "keyboard_name": kbf_data['keyboard.settings.name'].lower(),
161 "processor": mcu,
162 "bootloader": bootloader,
163 "diode_direction": diode_direction,
164 "matrix_pins": {
165 "cols": kbf_data['keyboard.pins.col'],
166 "rows": kbf_data['keyboard.pins.row'],
167 },
168 "layouts": {
169 "LAYOUT": {
170 "layout": layout,
171 }
172 }
173 })
174
175 if kbf_data['keyboard.pins.num'] or kbf_data['keyboard.pins.caps'] or kbf_data['keyboard.pins.scroll']:
176 if kbf_data['keyboard.pins.num']:
177 info_data['indicators.num_lock'] = kbf_data['keyboard.pins.num']
178 if kbf_data['keyboard.pins.caps']:
179 info_data['indicators.caps_lock'] = kbf_data['keyboard.pins.caps']
180 if kbf_data['keyboard.pins.scroll']:
181 info_data['indicators.scroll_lock'] = kbf_data['keyboard.pins.scroll']
182
183 if kbf_data['keyboard.pins.rgb']:
184 info_data['rgblight.animations'] = { # Comment here is to force multiline formatting
185 "breathing": True,
186 "rainbow_mood": True,
187 "rainbow_swirl": True,
188 "snake": True,
189 "knight": True,
190 "static_gradient": True,
191 "twinkle": True
192 }
193 info_data['rgblight.led_count'] = kbf_data['keyboard.settings.rgbNum']
194 info_data['ws2812.pin'] = kbf_data['keyboard.pins.rgb']
195
196 if kbf_data['keyboard.pins.led']:
197 info_data['backlight.levels'] = kbf_data['keyboard.settings.backlightLevels']
198 info_data['backlight.pin'] = kbf_data['keyboard.pins.led']
199
200 # delegate as if it were a regular keyboard import
201 return import_keyboard(info_data.to_dict(), keymap_data)
diff --git a/lib/python/qmk/info.py b/lib/python/qmk/info.py
new file mode 100644
index 0000000000..e07fa0ccae
--- /dev/null
+++ b/lib/python/qmk/info.py
@@ -0,0 +1,1131 @@
1"""Functions that help us generate and use info.json files.
2"""
3import re
4import os
5from pathlib import Path
6import jsonschema
7from dotty_dict import dotty
8from enum import IntFlag
9
10from milc import cli
11
12from qmk.constants import COL_LETTERS, ROW_LETTERS, CHIBIOS_PROCESSORS, LUFA_PROCESSORS, VUSB_PROCESSORS, JOYSTICK_AXES
13from qmk.c_parse import find_layouts, parse_config_h_file, find_led_config
14from qmk.json_schema import deep_update, json_load, validate
15from qmk.keyboard import config_h, rules_mk
16from qmk.commands import parse_configurator_json
17from qmk.makefile import parse_rules_mk_file
18from qmk.math_ops import compute
19from qmk.util import maybe_exit, truthy
20
21true_values = ['1', 'on', 'yes']
22false_values = ['0', 'off', 'no']
23
24
25class LedFlags(IntFlag):
26 ALL = 0xFF
27 NONE = 0x00
28 MODIFIER = 0x01
29 UNDERGLOW = 0x02
30 KEYLIGHT = 0x04
31 INDICATOR = 0x08
32
33
34def _keyboard_in_layout_name(keyboard, layout):
35 """Validate that a layout macro does not contain name of keyboard
36 """
37 # TODO: reduce this list down
38 safe_layout_tokens = {
39 'ansi',
40 'iso',
41 'jp',
42 'jis',
43 'ortho',
44 'wkl',
45 'tkl',
46 'preonic',
47 'planck',
48 }
49
50 # Ignore tokens like 'split_3x7_4' or just '2x4'
51 layout = re.sub(r"_split_\d+x\d+_\d+", '', layout)
52 layout = re.sub(r"_\d+x\d+", '', layout)
53
54 name_fragments = set(keyboard.split('/')) - safe_layout_tokens
55
56 return any(fragment in layout for fragment in name_fragments)
57
58
59def _valid_community_layout(layout):
60 """Validate that a declared community list exists
61 """
62 return (Path('layouts/default') / layout).exists()
63
64
65def _get_key_left_position(key):
66 # Special case for ISO enter
67 return key['x'] - 0.25 if key.get('h', 1) == 2 and key.get('w', 1) == 1.25 else key['x']
68
69
70def _find_invalid_encoder_index(info_data):
71 """Perform additional validation of encoders
72 """
73 enc_left = info_data.get('encoder', {}).get('rotary', [])
74 enc_right = []
75
76 if info_data.get('split', {}).get('enabled', False):
77 enc_right = info_data.get('split', {}).get('encoder', {}).get('right', {}).get('rotary', enc_left)
78
79 enc_count = len(enc_left) + len(enc_right)
80
81 ret = []
82 layouts = info_data.get('layouts', {})
83 for layout_name, layout_data in layouts.items():
84 found = set()
85 for key in layout_data['layout']:
86 if 'encoder' in key:
87 if enc_count == 0:
88 ret.append((layout_name, key['encoder'], 'non-configured'))
89 elif key['encoder'] >= enc_count:
90 ret.append((layout_name, key['encoder'], 'out of bounds'))
91 elif key['encoder'] in found:
92 ret.append((layout_name, key['encoder'], 'duplicate'))
93 found.add(key['encoder'])
94
95 return ret
96
97
98def _validate_build_target(keyboard, info_data):
99 """Non schema checks
100 """
101 keyboard_json_path = Path('keyboards') / keyboard / 'keyboard.json'
102 config_files = find_info_json(keyboard)
103
104 # keyboard.json can only exist at the deepest part of the tree
105 keyboard_json_count = 0
106 for info_file in config_files:
107 if info_file.name == 'keyboard.json':
108 keyboard_json_count += 1
109 if info_file != keyboard_json_path:
110 _log_error(info_data, f'Invalid keyboard.json location detected: {info_file}.')
111
112 # No keyboard.json next to info.json
113 for conf_file in config_files:
114 if conf_file.name == 'keyboard.json':
115 info_file = conf_file.parent / 'info.json'
116 if info_file.exists():
117 _log_error(info_data, f'Invalid info.json location detected: {info_file}.')
118
119 # Moving forward keyboard.json should be used as a build target
120 if keyboard_json_count == 0:
121 _log_warning(info_data, 'Build marker "keyboard.json" not found.')
122
123
124def _validate_layouts(keyboard, info_data): # noqa C901
125 """Non schema checks
126 """
127 col_num = info_data.get('matrix_size', {}).get('cols', 0)
128 row_num = info_data.get('matrix_size', {}).get('rows', 0)
129 layouts = info_data.get('layouts', {})
130 layout_aliases = info_data.get('layout_aliases', {})
131 community_layouts = info_data.get('community_layouts', [])
132 community_layouts_names = list(map(lambda layout: f'LAYOUT_{layout}', community_layouts))
133
134 # Make sure we have at least one layout
135 if len(layouts) == 0 or all(not layout.get('json_layout', False) for layout in layouts.values()):
136 _log_error(info_data, 'No LAYOUTs defined! Need at least one layout defined in info.json.')
137
138 # Make sure all layouts are DD
139 for layout_name, layout_data in layouts.items():
140 if layout_data.get('c_macro', False):
141 _log_error(info_data, f'{layout_name}: Layout macro should not be defined within ".h" files.')
142
143 # Make sure all matrix values are in bounds
144 for layout_name, layout_data in layouts.items():
145 for index, key_data in enumerate(layout_data['layout']):
146 row, col = key_data['matrix']
147 key_name = key_data.get('label', f'k{ROW_LETTERS[row]}{COL_LETTERS[col]}')
148 if row >= row_num:
149 _log_error(info_data, f'{layout_name}: Matrix row for key {index} ({key_name}) is {row} but must be less than {row_num}')
150 if col >= col_num:
151 _log_error(info_data, f'{layout_name}: Matrix column for key {index} ({key_name}) is {col} but must be less than {col_num}')
152
153 # Reject duplicate matrix locations
154 for layout_name, layout_data in layouts.items():
155 seen = set()
156 for index, key_data in enumerate(layout_data['layout']):
157 key = f"{key_data['matrix']}"
158 if key in seen:
159 _log_error(info_data, f'{layout_name}: Matrix location for key {index} is not unique {key_data}')
160 seen.add(key)
161
162 # Warn if physical positions are offset (at least one key should be at x=0, and at least one key at y=0)
163 for layout_name, layout_data in layouts.items():
164 offset_x = min([_get_key_left_position(k) for k in layout_data['layout']])
165 if offset_x > 0:
166 _log_warning(info_data, f'Layout "{layout_name}" is offset on X axis by {offset_x}')
167
168 offset_y = min([k['y'] for k in layout_data['layout']])
169 if offset_y > 0:
170 _log_warning(info_data, f'Layout "{layout_name}" is offset on Y axis by {offset_y}')
171
172 # Providing only LAYOUT_all "because I define my layouts in a 3rd party tool"
173 if len(layouts) == 1 and 'LAYOUT_all' in layouts:
174 _log_warning(info_data, '"LAYOUT_all" should be "LAYOUT" unless additional layouts are provided.')
175
176 # Extended layout name checks - ignoring community_layouts and "safe" values
177 potential_layouts = set(layouts.keys()) - set(community_layouts_names)
178 for layout in potential_layouts:
179 if _keyboard_in_layout_name(keyboard, layout):
180 _log_warning(info_data, f'Layout "{layout}" should not contain name of keyboard.')
181
182 # Filter out any non-existing community layouts
183 for layout in community_layouts:
184 if not _valid_community_layout(layout):
185 # Ignore layout from future checks
186 info_data['community_layouts'].remove(layout)
187 _log_error(info_data, 'Claims to support a community layout that does not exist: %s' % (layout))
188
189 # Make sure we supply layout macros for the community layouts we claim to support
190 for layout_name in community_layouts_names:
191 if layout_name not in layouts and layout_name not in layout_aliases:
192 _log_error(info_data, 'Claims to support community layout %s but no %s() macro found' % (layout, layout_name))
193
194
195def _validate_keycodes(keyboard, info_data):
196 """Non schema checks
197 """
198 # keycodes with length > 7 must have short forms for visualisation purposes
199 for decl in info_data.get('keycodes', []):
200 if len(decl["key"]) > 7:
201 if not decl.get("aliases", []):
202 _log_error(info_data, f'Keycode {decl["key"]} has no short form alias')
203
204
205def _validate_encoders(keyboard, info_data):
206 """Non schema checks
207 """
208 # encoder IDs in layouts must be in range and not duplicated
209 found = _find_invalid_encoder_index(info_data)
210 for layout_name, encoder_index, reason in found:
211 _log_error(info_data, f'Layout "{layout_name}" contains {reason} encoder index {encoder_index}.')
212
213
214def _validate(keyboard, info_data):
215 """Perform various validation on the provided info.json data
216 """
217 # First validate against the jsonschema
218 try:
219 validate(info_data, 'qmk.api.keyboard.v1')
220
221 # Additional validation
222 _validate_build_target(keyboard, info_data)
223 _validate_layouts(keyboard, info_data)
224 _validate_keycodes(keyboard, info_data)
225 _validate_encoders(keyboard, info_data)
226
227 except jsonschema.ValidationError as e:
228 json_path = '.'.join([str(p) for p in e.absolute_path])
229 cli.log.error('Invalid API data: %s: %s: %s', keyboard, json_path, e.message)
230 maybe_exit(1)
231
232
233def info_json(keyboard, force_layout=None):
234 """Generate the info.json data for a specific keyboard.
235 """
236 info_data = {
237 'keyboard_name': str(keyboard),
238 'keyboard_folder': str(keyboard),
239 'keymaps': {},
240 'layouts': {},
241 'parse_errors': [],
242 'parse_warnings': [],
243 'maintainer': 'qmk',
244 }
245
246 # Populate layout data
247 layouts, aliases = _search_keyboard_h(keyboard)
248
249 if aliases:
250 info_data['layout_aliases'] = aliases
251
252 for layout_name, layout_json in layouts.items():
253 if not layout_name.startswith('LAYOUT_kc'):
254 layout_json['c_macro'] = True
255 layout_json['json_layout'] = False
256 info_data['layouts'][layout_name] = layout_json
257
258 # Merge in the data from info.json, config.h, and rules.mk
259 info_data = merge_info_jsons(keyboard, info_data)
260 info_data = _process_defaults(info_data)
261 info_data = _extract_rules_mk(info_data, rules_mk(str(keyboard)))
262 info_data = _extract_config_h(info_data, config_h(str(keyboard)))
263
264 # Ensure that we have various calculated values
265 info_data = _matrix_size(info_data)
266 info_data = _joystick_axis_count(info_data)
267 info_data = _matrix_masked(info_data)
268
269 # Merge in data from <keyboard.c>
270 info_data = _extract_led_config(info_data, str(keyboard))
271
272 # Force a community layout if requested
273 community_layouts = info_data.get("community_layouts", [])
274 if force_layout in community_layouts:
275 info_data["community_layouts"] = [force_layout]
276
277 # Validate
278 # Skip processing if necessary
279 if not truthy(os.environ.get('SKIP_SCHEMA_VALIDATION'), False):
280 _validate(keyboard, info_data)
281
282 # Check that the reported matrix size is consistent with the actual matrix size
283 _check_matrix(info_data)
284
285 return info_data
286
287
288def _extract_features(info_data, rules):
289 """Find all the features enabled in rules.mk.
290 """
291 # Process booleans rules
292 for key, value in rules.items():
293 if key.endswith('_ENABLE'):
294 key = '_'.join(key.split('_')[:-1]).lower()
295 value = True if value.lower() in true_values else False if value.lower() in false_values else value
296
297 if key in ['lto']:
298 continue
299
300 if 'config_h_features' not in info_data:
301 info_data['config_h_features'] = {}
302
303 if 'features' not in info_data:
304 info_data['features'] = {}
305
306 if key in info_data['features']:
307 _log_warning(info_data, 'Feature %s is specified in both info.json (%s) and rules.mk (%s). The rules.mk value wins.' % (key, info_data['features'], value))
308
309 info_data['features'][key] = value
310 info_data['config_h_features'][key] = value
311
312 return info_data
313
314
315def _extract_matrix_rules(info_data, rules):
316 """Find all the features enabled in rules.mk.
317 """
318 if rules.get('CUSTOM_MATRIX', 'no') != 'no':
319 if 'matrix_pins' in info_data and 'custom' in info_data['matrix_pins']:
320 _log_warning(info_data, 'Custom Matrix is specified in both info.json and rules.mk, the rules.mk values win.')
321
322 if 'matrix_pins' not in info_data:
323 info_data['matrix_pins'] = {}
324
325 if rules['CUSTOM_MATRIX'] == 'lite':
326 info_data['matrix_pins']['custom_lite'] = True
327 else:
328 info_data['matrix_pins']['custom'] = True
329
330 return info_data
331
332
333def _pin_name(pin):
334 """Returns the proper representation for a pin.
335 """
336 pin = pin.strip()
337
338 if not pin:
339 return None
340
341 elif pin.isdigit():
342 return int(pin)
343
344 elif pin == 'NO_PIN':
345 return None
346
347 return pin
348
349
350def _extract_pins(pins):
351 """Returns a list of pins from a comma separated string of pins.
352 """
353 return [_pin_name(pin) for pin in pins.split(',')]
354
355
356def _extract_2d_array(raw):
357 """Return a 2d array of strings
358 """
359 out_array = []
360
361 while raw[-1] != '}':
362 raw = raw[:-1]
363
364 for row in raw.split('},{'):
365 if row.startswith('{'):
366 row = row[1:]
367
368 if row.endswith('}'):
369 row = row[:-1]
370
371 out_array.append([])
372
373 for val in row.split(','):
374 out_array[-1].append(val)
375
376 return out_array
377
378
379def _extract_2d_int_array(raw):
380 """Return a 2d array of ints
381 """
382 ret = _extract_2d_array(raw)
383
384 return [list(map(int, x)) for x in ret]
385
386
387def _extract_direct_matrix(direct_pins):
388 """extract direct_matrix
389 """
390 direct_pin_array = _extract_2d_array(direct_pins)
391
392 for i in range(len(direct_pin_array)):
393 for j in range(len(direct_pin_array[i])):
394 if direct_pin_array[i][j] == 'NO_PIN':
395 direct_pin_array[i][j] = None
396
397 return direct_pin_array
398
399
400def _extract_audio(info_data, config_c):
401 """Populate data about the audio configuration
402 """
403 audio_pins = []
404
405 for pin in 'B5', 'B6', 'B7', 'C4', 'C5', 'C6':
406 if config_c.get(f'{pin}_AUDIO'):
407 audio_pins.append(pin)
408
409 if audio_pins:
410 info_data['audio'] = {'pins': audio_pins}
411
412
413def _extract_encoders_values(config_c, postfix=''):
414 """Common encoder extraction logic
415 """
416 a_pad = config_c.get(f'ENCODER_A_PINS{postfix}', '').replace(' ', '')[1:-1]
417 b_pad = config_c.get(f'ENCODER_B_PINS{postfix}', '').replace(' ', '')[1:-1]
418 resolutions = config_c.get(f'ENCODER_RESOLUTIONS{postfix}', '').replace(' ', '')[1:-1]
419
420 default_resolution = config_c.get('ENCODER_RESOLUTION', None)
421
422 if a_pad and b_pad:
423 a_pad = list(filter(None, a_pad.split(',')))
424 b_pad = list(filter(None, b_pad.split(',')))
425 resolutions = list(filter(None, resolutions.split(',')))
426 if default_resolution:
427 resolutions += [default_resolution] * (len(a_pad) - len(resolutions))
428
429 encoders = []
430 for index in range(len(a_pad)):
431 encoder = {'pin_a': a_pad[index], 'pin_b': b_pad[index]}
432 if index < len(resolutions):
433 encoder['resolution'] = int(resolutions[index])
434 encoders.append(encoder)
435
436 return encoders
437
438
439def _extract_encoders(info_data, config_c):
440 """Populate data about encoder pins
441 """
442 encoders = _extract_encoders_values(config_c)
443 if encoders:
444 if 'encoder' not in info_data:
445 info_data['encoder'] = {}
446
447 if 'rotary' in info_data['encoder']:
448 _log_warning(info_data, 'Encoder config is specified in both config.h (%s) and info.json (%s). The config.h value wins.' % (encoders, info_data['encoder']['rotary']))
449
450 info_data['encoder']['rotary'] = encoders
451
452 # TODO: some logic still assumes ENCODER_ENABLED would partially create encoder dict
453 if info_data.get('features', {}).get('encoder', False):
454 if 'encoder' not in info_data:
455 info_data['encoder'] = {}
456 info_data['encoder']['enabled'] = True
457
458
459def _extract_split_encoders(info_data, config_c):
460 """Populate data about split encoder pins
461 """
462 encoders = _extract_encoders_values(config_c, '_RIGHT')
463 if encoders:
464 if 'split' not in info_data:
465 info_data['split'] = {}
466
467 if 'encoder' not in info_data['split']:
468 info_data['split']['encoder'] = {}
469
470 if 'right' not in info_data['split']['encoder']:
471 info_data['split']['encoder']['right'] = {}
472
473 if 'rotary' in info_data['split']['encoder']['right']:
474 _log_warning(info_data, 'Encoder config is specified in both config.h and info.json (encoder.rotary) (Value: %s), the config.h value wins.' % info_data['split']['encoder']['right']['rotary'])
475
476 info_data['split']['encoder']['right']['rotary'] = encoders
477
478
479def _extract_secure_unlock(info_data, config_c):
480 """Populate data about the secure unlock sequence
481 """
482 unlock = config_c.get('SECURE_UNLOCK_SEQUENCE', '').replace(' ', '')[1:-1]
483 if unlock:
484 unlock_array = _extract_2d_int_array(unlock)
485 if 'secure' not in info_data:
486 info_data['secure'] = {}
487
488 if 'unlock_sequence' in info_data['secure']:
489 _log_warning(info_data, 'Secure unlock sequence is specified in both config.h (SECURE_UNLOCK_SEQUENCE) and info.json (secure.unlock_sequence) (Value: %s), the config.h value wins.' % info_data['secure']['unlock_sequence'])
490
491 info_data['secure']['unlock_sequence'] = unlock_array
492
493
494def _extract_split_handedness(info_data, config_c):
495 # Migrate
496 split = info_data.get('split', {})
497 if 'matrix_grid' in split:
498 split['handedness'] = split.get('handedness', {})
499 split['handedness']['matrix_grid'] = split.pop('matrix_grid')
500
501
502def _extract_split_serial(info_data, config_c):
503 # Migrate
504 split = info_data.get('split', {})
505 if 'soft_serial_pin' in split:
506 split['serial'] = split.get('serial', {})
507 split['serial']['pin'] = split.pop('soft_serial_pin')
508 if 'soft_serial_speed' in split:
509 split['serial'] = split.get('serial', {})
510 split['serial']['speed'] = split.pop('soft_serial_speed')
511
512
513def _extract_split_transport(info_data, config_c):
514 # Figure out the transport method
515 if config_c.get('USE_I2C') is True:
516 if 'split' not in info_data:
517 info_data['split'] = {}
518
519 if 'transport' not in info_data['split']:
520 info_data['split']['transport'] = {}
521
522 if 'protocol' in info_data['split']['transport']:
523 _log_warning(info_data, 'Split transport is specified in both config.h (USE_I2C) and info.json (split.transport.protocol) (Value: %s), the config.h value wins.' % info_data['split']['transport'])
524
525 info_data['split']['transport']['protocol'] = 'i2c'
526
527 # Ignore transport defaults if "SPLIT_KEYBOARD" is unset
528 elif 'enabled' in info_data.get('split', {}):
529 if 'split' not in info_data:
530 info_data['split'] = {}
531
532 if 'transport' not in info_data['split']:
533 info_data['split']['transport'] = {}
534
535 if 'protocol' not in info_data['split']['transport']:
536 info_data['split']['transport']['protocol'] = 'serial'
537
538 # Migrate
539 transport = info_data.get('split', {}).get('transport', {})
540 if 'sync_matrix_state' in transport:
541 transport['sync'] = transport.get('sync', {})
542 transport['sync']['matrix_state'] = transport.pop('sync_matrix_state')
543 if 'sync_modifiers' in transport:
544 transport['sync'] = transport.get('sync', {})
545 transport['sync']['modifiers'] = transport.pop('sync_modifiers')
546
547
548def _extract_split_right_pins(info_data, config_c):
549 # Figure out the right half matrix pins
550 row_pins = config_c.get('MATRIX_ROW_PINS_RIGHT', '').replace('{', '').replace('}', '').strip()
551 col_pins = config_c.get('MATRIX_COL_PINS_RIGHT', '').replace('{', '').replace('}', '').strip()
552 direct_pins = config_c.get('DIRECT_PINS_RIGHT', '').replace(' ', '')[1:-1]
553
554 if row_pins or col_pins or direct_pins:
555 if info_data.get('split', {}).get('matrix_pins', {}).get('right', None):
556 _log_warning(info_data, 'Right hand matrix data is specified in both info.json and config.h, the config.h values win.')
557
558 if 'split' not in info_data:
559 info_data['split'] = {}
560
561 if 'matrix_pins' not in info_data['split']:
562 info_data['split']['matrix_pins'] = {}
563
564 if 'right' not in info_data['split']['matrix_pins']:
565 info_data['split']['matrix_pins']['right'] = {}
566
567 if col_pins:
568 info_data['split']['matrix_pins']['right']['cols'] = _extract_pins(col_pins)
569
570 if row_pins:
571 info_data['split']['matrix_pins']['right']['rows'] = _extract_pins(row_pins)
572
573 if direct_pins:
574 info_data['split']['matrix_pins']['right']['direct'] = _extract_direct_matrix(direct_pins)
575
576
577def _extract_matrix_info(info_data, config_c):
578 """Populate the matrix information.
579 """
580 row_pins = config_c.get('MATRIX_ROW_PINS', '').replace('{', '').replace('}', '').strip()
581 col_pins = config_c.get('MATRIX_COL_PINS', '').replace('{', '').replace('}', '').strip()
582 direct_pins = config_c.get('DIRECT_PINS', '').replace(' ', '')[1:-1]
583
584 if 'MATRIX_ROWS' in config_c and 'MATRIX_COLS' in config_c:
585 if 'matrix_size' in info_data:
586 _log_warning(info_data, 'Matrix size is specified in both info.json and config.h, the config.h values win.')
587
588 info_data['matrix_size'] = {
589 'cols': compute(config_c.get('MATRIX_COLS', '0')),
590 'rows': compute(config_c.get('MATRIX_ROWS', '0')),
591 }
592
593 if row_pins and col_pins:
594 if 'matrix_pins' in info_data and 'cols' in info_data['matrix_pins'] and 'rows' in info_data['matrix_pins']:
595 _log_warning(info_data, 'Matrix pins are specified in both info.json and config.h, the config.h values win.')
596
597 if 'matrix_pins' not in info_data:
598 info_data['matrix_pins'] = {}
599
600 info_data['matrix_pins']['cols'] = _extract_pins(col_pins)
601 info_data['matrix_pins']['rows'] = _extract_pins(row_pins)
602
603 if direct_pins:
604 if 'matrix_pins' in info_data and 'direct' in info_data['matrix_pins']:
605 _log_warning(info_data, 'Direct pins are specified in both info.json and config.h, the config.h values win.')
606
607 if 'matrix_pins' not in info_data:
608 info_data['matrix_pins'] = {}
609
610 info_data['matrix_pins']['direct'] = _extract_direct_matrix(direct_pins)
611
612 return info_data
613
614
615def _config_to_json(key_type, config_value):
616 """Convert config value using spec
617 """
618 if key_type.startswith('array'):
619 if key_type.count('.') > 1:
620 raise Exception(f"Conversion of {key_type} not possible")
621
622 if '.' in key_type:
623 key_type, array_type = key_type.split('.', 1)
624 else:
625 array_type = None
626
627 config_value = config_value.replace('{', '').replace('}', '').strip()
628
629 if array_type == 'int':
630 return list(map(int, config_value.split(',')))
631 else:
632 return list(map(str.strip, config_value.split(',')))
633
634 elif key_type in ['bool', 'flag']:
635 if isinstance(config_value, bool):
636 return config_value
637 return config_value in true_values
638
639 elif key_type == 'hex':
640 return '0x' + config_value[2:].upper()
641
642 elif key_type == 'list':
643 return config_value.split()
644
645 elif key_type == 'int':
646 return int(config_value)
647
648 elif key_type == 'str':
649 return config_value.strip('"').replace('\\"', '"').replace('\\\\', '\\')
650
651 elif key_type == 'bcd_version':
652 major = int(config_value[2:4])
653 minor = int(config_value[4])
654 revision = int(config_value[5])
655
656 return f'{major}.{minor}.{revision}'
657
658 return config_value
659
660
661def _extract_config_h(info_data, config_c):
662 """Pull some keyboard information from existing config.h files
663 """
664 # Pull in data from the json map
665 dotty_info = dotty(info_data)
666 info_config_map = json_load(Path('data/mappings/info_config.hjson'))
667
668 for config_key, info_dict in info_config_map.items():
669 info_key = info_dict['info_key']
670 key_type = info_dict.get('value_type', 'raw')
671
672 try:
673 replace_with = info_dict.get('replace_with')
674 if config_key in config_c and info_dict.get('invalid', False):
675 if replace_with:
676 _log_error(info_data, '%s in config.h is no longer a valid option and should be replaced with %s' % (config_key, replace_with))
677 else:
678 _log_error(info_data, '%s in config.h is no longer a valid option and should be removed' % config_key)
679 elif config_key in config_c and info_dict.get('deprecated', False):
680 if replace_with:
681 _log_warning(info_data, '%s in config.h is deprecated in favor of %s and will be removed at a later date' % (config_key, replace_with))
682 else:
683 _log_warning(info_data, '%s in config.h is deprecated and will be removed at a later date' % config_key)
684
685 if config_key in config_c and info_dict.get('to_json', True):
686 if dotty_info.get(info_key) and info_dict.get('warn_duplicate', True):
687 _log_warning(info_data, '%s in config.h is overwriting %s in info.json' % (config_key, info_key))
688
689 dotty_info[info_key] = _config_to_json(key_type, config_c[config_key])
690
691 except Exception as e:
692 _log_warning(info_data, f'{config_key}->{info_key}: {e}')
693
694 info_data.update(dotty_info)
695
696 # Pull data that easily can't be mapped in json
697 _extract_matrix_info(info_data, config_c)
698 _extract_audio(info_data, config_c)
699 _extract_secure_unlock(info_data, config_c)
700 _extract_split_handedness(info_data, config_c)
701 _extract_split_serial(info_data, config_c)
702 _extract_split_transport(info_data, config_c)
703 _extract_split_right_pins(info_data, config_c)
704 _extract_encoders(info_data, config_c)
705 _extract_split_encoders(info_data, config_c)
706
707 return info_data
708
709
710def _process_defaults(info_data):
711 """Process any additional defaults based on currently discovered information
712 """
713 defaults_map = json_load(Path('data/mappings/defaults.hjson'))
714 for default_type in defaults_map.keys():
715 thing_map = defaults_map[default_type]
716 if default_type in info_data:
717 merged_count = 0
718 thing_items = thing_map.get(info_data[default_type], {}).items()
719 for key, value in thing_items:
720 if key not in info_data:
721 info_data[key] = value
722 merged_count += 1
723
724 if merged_count == 0 and len(thing_items) > 0:
725 _log_warning(info_data, 'All defaults for \'%s\' were skipped, potential redundant config or misconfiguration detected' % (default_type))
726
727 return info_data
728
729
730def _extract_rules_mk(info_data, rules):
731 """Pull some keyboard information from existing rules.mk files
732 """
733 info_data['processor'] = rules.get('MCU', info_data.get('processor', 'atmega32u4'))
734
735 if info_data['processor'] in CHIBIOS_PROCESSORS:
736 arm_processor_rules(info_data, rules)
737
738 elif info_data['processor'] in LUFA_PROCESSORS + VUSB_PROCESSORS:
739 avr_processor_rules(info_data, rules)
740
741 else:
742 cli.log.warning("%s: Unknown MCU: %s" % (info_data['keyboard_folder'], info_data['processor']))
743 unknown_processor_rules(info_data, rules)
744
745 # Pull in data from the json map
746 dotty_info = dotty(info_data)
747 info_rules_map = json_load(Path('data/mappings/info_rules.hjson'))
748
749 for rules_key, info_dict in info_rules_map.items():
750 info_key = info_dict['info_key']
751 key_type = info_dict.get('value_type', 'raw')
752
753 try:
754 replace_with = info_dict.get('replace_with')
755 if rules_key in rules and info_dict.get('invalid', False):
756 if replace_with:
757 _log_error(info_data, '%s in rules.mk is no longer a valid option and should be replaced with %s' % (rules_key, replace_with))
758 else:
759 _log_error(info_data, '%s in rules.mk is no longer a valid option and should be removed' % rules_key)
760 elif rules_key in rules and info_dict.get('deprecated', False):
761 if replace_with:
762 _log_warning(info_data, '%s in rules.mk is deprecated in favor of %s and will be removed at a later date' % (rules_key, replace_with))
763 else:
764 _log_warning(info_data, '%s in rules.mk is deprecated and will be removed at a later date' % rules_key)
765
766 if rules_key in rules and info_dict.get('to_json', True):
767 if dotty_info.get(info_key) and info_dict.get('warn_duplicate', True):
768 _log_warning(info_data, '%s in rules.mk is overwriting %s in info.json' % (rules_key, info_key))
769
770 dotty_info[info_key] = _config_to_json(key_type, rules[rules_key])
771
772 except Exception as e:
773 _log_warning(info_data, f'{rules_key}->{info_key}: {e}')
774
775 info_data.update(dotty_info)
776
777 # Merge in config values that can't be easily mapped
778 _extract_features(info_data, rules)
779 _extract_matrix_rules(info_data, rules)
780
781 return info_data
782
783
784def find_keyboard_c(keyboard):
785 """Find all <keyboard>.c files
786 """
787 keyboard = Path(keyboard)
788 current_path = Path('keyboards/')
789
790 files = []
791 for directory in keyboard.parts:
792 current_path = current_path / directory
793 keyboard_c_path = current_path / f'{directory}.c'
794 if keyboard_c_path.exists():
795 files.append(keyboard_c_path)
796
797 return files
798
799
800def _extract_led_config(info_data, keyboard):
801 """Scan all <keyboard>.c files for led config
802 """
803 for feature in ['rgb_matrix', 'led_matrix']:
804 if info_data.get('features', {}).get(feature, False) or feature in info_data:
805 # Only attempt search if dd led config is missing
806 if 'layout' not in info_data.get(feature, {}):
807 cols = info_data.get('matrix_size', {}).get('cols')
808 rows = info_data.get('matrix_size', {}).get('rows')
809 if cols and rows:
810 # Process
811 for file in find_keyboard_c(keyboard):
812 try:
813 ret = find_led_config(file, cols, rows)
814 if ret:
815 info_data[feature] = info_data.get(feature, {})
816 info_data[feature]['layout'] = ret
817 except Exception as e:
818 _log_warning(info_data, f'led_config: {file.name}: {e}')
819 else:
820 _log_warning(info_data, 'led_config: matrix size required to parse g_led_config')
821
822 if info_data[feature].get('layout', None) and not info_data[feature].get('led_count', None):
823 info_data[feature]['led_count'] = len(info_data[feature]['layout'])
824
825 if info_data[feature].get('layout', None) and not info_data[feature].get('flag_steps', None):
826 flags = {LedFlags.ALL, LedFlags.NONE}
827 default_flags = {LedFlags.MODIFIER | LedFlags.KEYLIGHT, LedFlags.UNDERGLOW}
828
829 # if only a single flag is used, assume only all+none flags
830 kb_flags = set(x.get('flags', LedFlags.NONE) for x in info_data[feature]['layout'])
831 if len(kb_flags) > 1:
832 # check if any part of LED flag is with the defaults
833 unique_flags = set()
834 for candidate in default_flags:
835 if any(candidate & flag for flag in kb_flags):
836 unique_flags.add(candidate)
837
838 # if we still have a single flag, assume only all+none
839 if len(unique_flags) > 1:
840 flags.update(unique_flags)
841
842 info_data[feature]['flag_steps'] = sorted([int(flag) for flag in flags], reverse=True)
843
844 return info_data
845
846
847def _matrix_size(info_data):
848 """Add info_data['matrix_size'] if it doesn't exist.
849 """
850 if 'matrix_size' not in info_data and 'matrix_pins' in info_data:
851 info_data['matrix_size'] = {}
852
853 if 'direct' in info_data['matrix_pins']:
854 info_data['matrix_size']['cols'] = len(info_data['matrix_pins']['direct'][0])
855 info_data['matrix_size']['rows'] = len(info_data['matrix_pins']['direct'])
856 elif 'cols' in info_data['matrix_pins'] and 'rows' in info_data['matrix_pins']:
857 info_data['matrix_size']['cols'] = len(info_data['matrix_pins']['cols'])
858 info_data['matrix_size']['rows'] = len(info_data['matrix_pins']['rows'])
859
860 # Assumption of split common
861 if 'split' in info_data:
862 if info_data['split'].get('enabled', False):
863 info_data['matrix_size']['rows'] *= 2
864
865 return info_data
866
867
868def _joystick_axis_count(info_data):
869 """Add info_data['joystick.axis_count'] if required
870 """
871 if 'axes' in info_data.get('joystick', {}):
872 axes_keys = info_data['joystick']['axes'].keys()
873 info_data['joystick']['axis_count'] = max(JOYSTICK_AXES.index(a) for a in axes_keys) + 1 if axes_keys else 0
874
875 return info_data
876
877
878def _matrix_masked(info_data):
879 """"Add info_data['matrix_pins.masked'] if required"""
880 mask_required = False
881
882 if 'matrix_grid' in info_data.get('dip_switch', {}):
883 mask_required = True
884 if 'matrix_grid' in info_data.get('split', {}).get('handedness', {}):
885 mask_required = True
886
887 if mask_required:
888 if 'masked' not in info_data.get('matrix_pins', {}):
889 if 'matrix_pins' not in info_data:
890 info_data['matrix_pins'] = {}
891
892 info_data['matrix_pins']['masked'] = True
893
894 return info_data
895
896
897def _check_matrix(info_data):
898 """Check the matrix to ensure that row/column count is consistent.
899 """
900 if 'matrix_pins' in info_data and 'matrix_size' in info_data:
901 actual_col_count = info_data['matrix_size'].get('cols', 0)
902 actual_row_count = info_data['matrix_size'].get('rows', 0)
903 col_count = row_count = 0
904
905 if 'direct' in info_data['matrix_pins']:
906 col_count = len(info_data['matrix_pins']['direct'][0])
907 row_count = len(info_data['matrix_pins']['direct'])
908 elif 'cols' in info_data['matrix_pins'] and 'rows' in info_data['matrix_pins']:
909 col_count = len(info_data['matrix_pins']['cols'])
910 row_count = len(info_data['matrix_pins']['rows'])
911 elif 'cols' not in info_data['matrix_pins'] and 'rows' not in info_data['matrix_pins']:
912 # This case caters for custom matrix implementations where normal rows/cols are specified
913 return
914
915 if col_count != actual_col_count and col_count != (actual_col_count / 2):
916 # FIXME: once we can we should detect if split is enabled to do the actual_col_count/2 check.
917 _log_error(info_data, f'MATRIX_COLS is inconsistent with the size of MATRIX_COL_PINS: {col_count} != {actual_col_count}')
918
919 if row_count != actual_row_count and row_count != (actual_row_count / 2):
920 # FIXME: once we can we should detect if split is enabled to do the actual_row_count/2 check.
921 _log_error(info_data, f'MATRIX_ROWS is inconsistent with the size of MATRIX_ROW_PINS: {row_count} != {actual_row_count}')
922
923
924def _search_keyboard_h(keyboard):
925 keyboard = Path(keyboard)
926 current_path = Path('keyboards/')
927 aliases = {}
928 layouts = {}
929
930 for directory in keyboard.parts:
931 current_path = current_path / directory
932 keyboard_h = '%s.h' % (directory,)
933 keyboard_h_path = current_path / keyboard_h
934 if keyboard_h_path.exists():
935 new_layouts, new_aliases = find_layouts(keyboard_h_path)
936 layouts.update(new_layouts)
937
938 for alias, alias_text in new_aliases.items():
939 if alias_text in layouts:
940 aliases[alias] = alias_text
941
942 return layouts, aliases
943
944
945def _log_error(info_data, message):
946 """Send an error message to both JSON and the log.
947 """
948 info_data['parse_errors'].append(message)
949 cli.log.error('%s: %s', info_data.get('keyboard_folder', 'Unknown Keyboard!'), message)
950
951
952def _log_warning(info_data, message):
953 """Send a warning message to both JSON and the log.
954 """
955 info_data['parse_warnings'].append(message)
956 cli.log.warning('%s: %s', info_data.get('keyboard_folder', 'Unknown Keyboard!'), message)
957
958
959def arm_processor_rules(info_data, rules):
960 """Setup the default info for an ARM board.
961 """
962 info_data['processor_type'] = 'arm'
963 info_data['protocol'] = 'ChibiOS'
964 info_data['platform_key'] = 'chibios'
965
966 if 'STM32' in info_data['processor']:
967 info_data['platform'] = 'STM32'
968 elif 'MCU_SERIES' in rules:
969 info_data['platform'] = rules['MCU_SERIES']
970
971 return info_data
972
973
974def avr_processor_rules(info_data, rules):
975 """Setup the default info for an AVR board.
976 """
977 info_data['processor_type'] = 'avr'
978 info_data['platform'] = rules['ARCH'] if 'ARCH' in rules else 'unknown'
979 info_data['platform_key'] = 'avr'
980 info_data['protocol'] = 'V-USB' if info_data['processor'] in VUSB_PROCESSORS else 'LUFA'
981
982 # FIXME(fauxpark/anyone): Eventually we should detect the protocol by looking at PROTOCOL inherited from mcu_selection.mk:
983 # info_data['protocol'] = 'V-USB' if rules.get('PROTOCOL') == 'VUSB' else 'LUFA'
984
985 return info_data
986
987
988def unknown_processor_rules(info_data, rules):
989 """Setup the default keyboard info for unknown boards.
990 """
991 info_data['bootloader'] = 'unknown'
992 info_data['platform'] = 'unknown'
993 info_data['processor'] = 'unknown'
994 info_data['processor_type'] = 'unknown'
995 info_data['protocol'] = 'unknown'
996
997 return info_data
998
999
1000def merge_info_jsons(keyboard, info_data):
1001 """Return a merged copy of all the info.json files for a keyboard.
1002 """
1003 config_files = find_info_json(keyboard)
1004
1005 for info_file in config_files:
1006 # Load and validate the JSON data
1007 new_info_data = json_load(info_file)
1008
1009 if not isinstance(new_info_data, dict):
1010 _log_error(info_data, "Invalid file %s, root object should be a dictionary." % (str(info_file),))
1011 continue
1012
1013 if not truthy(os.environ.get('SKIP_SCHEMA_VALIDATION'), False):
1014 try:
1015 validate(new_info_data, 'qmk.keyboard.v1')
1016 except jsonschema.ValidationError as e:
1017 json_path = '.'.join([str(p) for p in e.absolute_path])
1018 cli.log.error('Not including data from file: %s', info_file)
1019 cli.log.error('\t%s: %s', json_path, e.message)
1020 continue
1021
1022 # Merge layout data in
1023 if 'layout_aliases' in new_info_data:
1024 info_data['layout_aliases'] = {**info_data.get('layout_aliases', {}), **new_info_data['layout_aliases']}
1025 del new_info_data['layout_aliases']
1026
1027 for layout_name, layout in new_info_data.get('layouts', {}).items():
1028 if layout_name in info_data.get('layout_aliases', {}):
1029 _log_warning(info_data, f"info.json uses alias name {layout_name} instead of {info_data['layout_aliases'][layout_name]}")
1030 layout_name = info_data['layout_aliases'][layout_name]
1031
1032 if layout_name in info_data['layouts']:
1033 if len(info_data['layouts'][layout_name]['layout']) != len(layout['layout']):
1034 msg = 'Number of keys for %s does not match! info.json specifies %d keys, C macro specifies %d'
1035 _log_error(info_data, msg % (layout_name, len(layout['layout']), len(info_data['layouts'][layout_name]['layout'])))
1036 else:
1037 info_data['layouts'][layout_name]['json_layout'] = True
1038 for new_key, existing_key in zip(layout['layout'], info_data['layouts'][layout_name]['layout']):
1039 existing_key.update(new_key)
1040 else:
1041 if not all('matrix' in key_data.keys() for key_data in layout['layout']):
1042 _log_error(info_data, f'Layout "{layout_name}" has no "matrix" definition in either "info.json" or "<keyboard>.h"!')
1043 else:
1044 layout['c_macro'] = False
1045 layout['json_layout'] = True
1046 info_data['layouts'][layout_name] = layout
1047
1048 # Update info_data with the new data
1049 if 'layouts' in new_info_data:
1050 del new_info_data['layouts']
1051
1052 deep_update(info_data, new_info_data)
1053
1054 return info_data
1055
1056
1057def find_info_json(keyboard):
1058 """Finds all the info.json files associated with a keyboard.
1059 """
1060 # Find the most specific first
1061 base_path = Path('keyboards')
1062 keyboard_path = base_path / keyboard
1063 keyboard_parent = keyboard_path.parent
1064 info_jsons = [keyboard_path / 'info.json', keyboard_path / 'keyboard.json']
1065
1066 # Add in parent folders for least specific
1067 for _ in range(5):
1068 if keyboard_parent == base_path:
1069 break
1070 info_jsons.append(keyboard_parent / 'info.json')
1071 info_jsons.append(keyboard_parent / 'keyboard.json')
1072 keyboard_parent = keyboard_parent.parent
1073
1074 # Return a list of the info.json files that actually exist
1075 return [info_json for info_json in info_jsons if info_json.exists()]
1076
1077
1078def keymap_json_config(keyboard, keymap, force_layout=None):
1079 """Extract keymap level config
1080 """
1081 # TODO: resolve keymap.py and info.py circular dependencies
1082 from qmk.keymap import locate_keymap
1083
1084 keymap_folder = locate_keymap(keyboard, keymap, force_layout=force_layout).parent
1085
1086 km_info_json = parse_configurator_json(keymap_folder / 'keymap.json')
1087 return km_info_json.get('config', {})
1088
1089
1090def keymap_json(keyboard, keymap, force_layout=None):
1091 """Generate the info.json data for a specific keymap.
1092 """
1093 # TODO: resolve keymap.py and info.py circular dependencies
1094 from qmk.keymap import locate_keymap
1095
1096 keymap_folder = locate_keymap(keyboard, keymap, force_layout=force_layout).parent
1097
1098 # Files to scan
1099 keymap_config = keymap_folder / 'config.h'
1100 keymap_rules = keymap_folder / 'rules.mk'
1101 keymap_file = keymap_folder / 'keymap.json'
1102
1103 # Build the info.json file
1104 kb_info_json = info_json(keyboard, force_layout=force_layout)
1105
1106 # Merge in the data from keymap.json
1107 km_info_json = keymap_json_config(keyboard, keymap, force_layout=force_layout) if keymap_file.exists() else {}
1108 deep_update(kb_info_json, km_info_json)
1109
1110 # Merge in the data from config.h, and rules.mk
1111 _extract_rules_mk(kb_info_json, parse_rules_mk_file(keymap_rules))
1112 _extract_config_h(kb_info_json, parse_config_h_file(keymap_config))
1113
1114 return kb_info_json
1115
1116
1117def get_modules(keyboard, keymap_filename):
1118 """Get the modules for a keyboard/keymap.
1119 """
1120 modules = []
1121
1122 kb_info_json = info_json(keyboard)
1123 modules.extend(kb_info_json.get('modules', []))
1124
1125 if keymap_filename:
1126 keymap_json = parse_configurator_json(keymap_filename)
1127
1128 if keymap_json:
1129 modules.extend(keymap_json.get('modules', []))
1130
1131 return list(dict.fromkeys(modules)) # remove dupes
diff --git a/lib/python/qmk/json_encoders.py b/lib/python/qmk/json_encoders.py
new file mode 100755
index 0000000000..6bad820a76
--- /dev/null
+++ b/lib/python/qmk/json_encoders.py
@@ -0,0 +1,267 @@
1"""Class that pretty-prints QMK info.json files.
2"""
3import json
4from decimal import Decimal
5
6newline = '\n'
7
8
9class QMKJSONEncoder(json.JSONEncoder):
10 """Base class for all QMK JSON encoders.
11 """
12 container_types = (list, tuple, dict)
13 indentation_char = " "
14
15 def __init__(self, *args, **kwargs):
16 super().__init__(*args, **kwargs)
17 self.indentation_level = 0
18
19 if not self.indent:
20 self.indent = 4
21
22 def encode_decimal(self, obj):
23 """Encode a decimal object.
24 """
25 if obj == int(obj): # I can't believe Decimal objects don't have .is_integer()
26 return int(obj)
27
28 return float(obj)
29
30 def encode_dict(self, obj, path):
31 """Encode a dict-like object.
32 """
33 if obj:
34 self.indentation_level += 1
35
36 items = sorted(obj.items(), key=self.sort_dict) if self.sort_keys else obj.items()
37 output = [self.indent_str + f"{json.dumps(key)}: {self.encode(value, path + [key])}" for key, value in items]
38
39 self.indentation_level -= 1
40
41 return "{\n" + ",\n".join(output) + "\n" + self.indent_str + "}"
42 else:
43 return "{}"
44
45 def encode_dict_single_line(self, obj, path):
46 """Encode a dict-like object onto a single line.
47 """
48 return "{" + ", ".join(f"{json.dumps(key)}: {self.encode(value, path + [key])}" for key, value in sorted(obj.items(), key=self.sort_layout)) + "}"
49
50 def encode_list(self, obj, path):
51 """Encode a list-like object.
52 """
53 if self.primitives_only(obj):
54 return "[" + ", ".join(self.encode(value, path + [index]) for index, value in enumerate(obj)) + "]"
55
56 else:
57 self.indentation_level += 1
58
59 if path[-1] in ('layout', 'rotary'):
60 # These are part of a LED layout or encoder config, put them on a single line
61 output = [self.indent_str + self.encode_dict_single_line(value, path + [index]) for index, value in enumerate(obj)]
62 else:
63 output = [self.indent_str + self.encode(value, path + [index]) for index, value in enumerate(obj)]
64
65 self.indentation_level -= 1
66
67 return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]"
68
69 def encode(self, obj, path=[]):
70 """Encode JSON objects for QMK.
71 """
72 if isinstance(obj, Decimal):
73 return self.encode_decimal(obj)
74
75 elif isinstance(obj, (list, tuple)):
76 return self.encode_list(obj, path)
77
78 elif isinstance(obj, dict):
79 return self.encode_dict(obj, path)
80
81 else:
82 return super().encode(obj)
83
84 def primitives_only(self, obj):
85 """Returns true if the object doesn't have any container type objects (list, tuple, dict).
86 """
87 if isinstance(obj, dict):
88 obj = obj.values()
89
90 return not any(isinstance(element, self.container_types) for element in obj)
91
92 @property
93 def indent_str(self):
94 return self.indentation_char * (self.indentation_level * self.indent)
95
96
97class InfoJSONEncoder(QMKJSONEncoder):
98 """Custom encoder to make info.json's a little nicer to work with.
99 """
100 def sort_layout(self, item):
101 """Sorts the hashes in a nice way.
102 """
103 key = item[0]
104
105 if key == 'label':
106 return '00label'
107
108 elif key == 'matrix':
109 return '01matrix'
110
111 elif key == 'x':
112 return '02x'
113
114 elif key == 'y':
115 return '03y'
116
117 elif key == 'w':
118 return '04w'
119
120 elif key == 'h':
121 return '05h'
122
123 elif key == 'flags':
124 return '06flags'
125
126 return key
127
128 def sort_dict(self, item):
129 """Forces layout to the back of the sort order.
130 """
131 key = item[0]
132
133 if self.indentation_level == 1:
134 if key == 'manufacturer':
135 return '10manufacturer'
136
137 elif key == 'keyboard_name':
138 return '11keyboard_name'
139
140 elif key == 'maintainer':
141 return '12maintainer'
142
143 elif key == 'community_layouts':
144 return '97community_layouts'
145
146 elif key == 'layout_aliases':
147 return '98layout_aliases'
148
149 elif key == 'layouts':
150 return '99layouts'
151
152 else:
153 return '50' + str(key)
154
155 return key
156
157
158class KeymapJSONEncoder(QMKJSONEncoder):
159 """Custom encoder to make keymap.json's a little nicer to work with.
160 """
161 def encode_list(self, obj, path):
162 """Encode a list-like object.
163 """
164 if self.indentation_level == 2:
165 indent_level = self.indentation_level + 1
166 # We have a list of keycodes
167 layer = [[]]
168
169 for key in obj:
170 if key == 'JSON_NEWLINE':
171 layer.append([])
172 else:
173 if isinstance(key, dict):
174 # We have a macro
175
176 # TODO: Add proper support for nicely formatting keymap.json macros
177 layer[-1].append(f'{self.encode(key)}')
178 else:
179 layer[-1].append(f'"{key}"')
180
181 layer = [f"{self.indent_str * indent_level}{', '.join(row)}" for row in layer]
182
183 return f"{self.indent_str}[\n{newline.join(layer)}\n{self.indent_str * self.indentation_level}]"
184
185 elif self.primitives_only(obj):
186 return "[" + ", ".join(self.encode(element) for element in obj) + "]"
187
188 else:
189 self.indentation_level += 1
190 output = [self.indent_str + self.encode(element) for element in obj]
191 self.indentation_level -= 1
192
193 return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]"
194
195 def sort_dict(self, item):
196 """Sorts the hashes in a nice way.
197 """
198 key = item[0]
199
200 if self.indentation_level == 1:
201 if key == 'version':
202 return '00version'
203
204 elif key == 'author':
205 return '01author'
206
207 elif key == 'notes':
208 return '02notes'
209
210 elif key == 'layers':
211 return '98layers'
212
213 elif key == 'documentation':
214 return '99documentation'
215
216 else:
217 return '50' + str(key)
218
219 return key
220
221
222class UserspaceJSONEncoder(QMKJSONEncoder):
223 """Custom encoder to make userspace qmk.json's a little nicer to work with.
224 """
225 def sort_dict(self, item):
226 """Sorts the hashes in a nice way.
227 """
228 key = item[0]
229
230 if self.indentation_level == 1:
231 if key == 'userspace_version':
232 return '00userspace_version'
233
234 if key == 'build_targets':
235 return '01build_targets'
236
237 return key
238
239
240class CommunityModuleJSONEncoder(QMKJSONEncoder):
241 """Custom encoder to make qmk_module.json's a little nicer to work with.
242 """
243 def sort_dict(self, item):
244 """Sorts the hashes in a nice way.
245 """
246 key = item[0]
247
248 if self.indentation_level == 1:
249 if key == 'module_name':
250 return '00module_name'
251 if key == 'maintainer':
252 return '01maintainer'
253 if key == 'license':
254 return '02license'
255 if key == 'url':
256 return '03url'
257 if key == 'features':
258 return '04features'
259 if key == 'keycodes':
260 return '05keycodes'
261 elif self.indentation_level == 3: # keycodes
262 if key == 'key':
263 return '00key'
264 if key == 'aliases':
265 return '01aliases'
266
267 return key
diff --git a/lib/python/qmk/json_schema.py b/lib/python/qmk/json_schema.py
new file mode 100644
index 0000000000..e871598565
--- /dev/null
+++ b/lib/python/qmk/json_schema.py
@@ -0,0 +1,151 @@
1"""Functions that help us generate and use info.json files.
2"""
3import json
4import hjson
5import jsonschema
6from collections.abc import Mapping
7from functools import lru_cache
8from typing import OrderedDict
9from pathlib import Path
10from copy import deepcopy
11
12from milc import cli
13
14from qmk.util import maybe_exit
15
16
17def _dict_raise_on_duplicates(ordered_pairs):
18 """Reject duplicate keys."""
19 d = {}
20 for k, v in ordered_pairs:
21 if k in d:
22 raise ValueError("duplicate key: %r" % (k,))
23 else:
24 d[k] = v
25 return d
26
27
28@lru_cache(maxsize=20)
29def _json_load_impl(json_file, strict=True):
30 """Load a json file from disk.
31
32 Note: file must be a Path object.
33 """
34 try:
35 # Get the IO Stream for Path objects
36 # Not necessary if the data is provided via stdin
37 if isinstance(json_file, Path):
38 json_file = json_file.open(encoding='utf-8')
39 return hjson.load(json_file, object_pairs_hook=_dict_raise_on_duplicates if strict else None)
40
41 except (json.decoder.JSONDecodeError, hjson.HjsonDecodeError) as e:
42 cli.log.error('Invalid JSON encountered attempting to load {fg_cyan}%s{fg_reset}:\n\t{fg_red}%s', json_file, e)
43 maybe_exit(1)
44 except Exception as e:
45 cli.log.error('Unknown error attempting to load {fg_cyan}%s{fg_reset}:\n\t{fg_red}%s', json_file, e)
46 maybe_exit(1)
47
48
49def json_load(json_file, strict=True):
50 return deepcopy(_json_load_impl(json_file=json_file, strict=strict))
51
52
53@lru_cache(maxsize=20)
54def load_jsonschema(schema_name):
55 """Read a jsonschema file from disk.
56 """
57 if Path(schema_name).exists():
58 return json_load(schema_name)
59
60 schema_path = Path(f'data/schemas/{schema_name}.jsonschema')
61
62 if not schema_path.exists():
63 schema_path = Path('data/schemas/false.jsonschema')
64
65 return json_load(schema_path)
66
67
68@lru_cache(maxsize=1)
69def compile_schema_store():
70 """Compile all our schemas into a schema store.
71 """
72 schema_store = {}
73
74 for schema_file in Path('data/schemas').glob('*.jsonschema'):
75 schema_data = load_jsonschema(schema_file)
76 if not isinstance(schema_data, dict):
77 cli.log.debug('Skipping schema file %s', schema_file)
78 continue
79
80 # `$id`-based references
81 schema_store[schema_data['$id']] = schema_data
82
83 # Path-based references
84 schema_store[Path(schema_file).name] = schema_data
85
86 return schema_store
87
88
89@lru_cache(maxsize=20)
90def create_validator(schema):
91 """Creates a validator for the given schema id.
92 """
93 schema_store = compile_schema_store()
94 resolver = jsonschema.RefResolver.from_schema(schema_store[schema], store=schema_store)
95
96 return jsonschema.Draft202012Validator(schema_store[schema], resolver=resolver).validate
97
98
99def validate(data, schema):
100 """Validates data against a schema.
101 """
102 validator = create_validator(schema)
103
104 return validator(data)
105
106
107def deep_update(origdict, newdict):
108 """Update a dictionary in place, recursing to do a depth-first deep copy.
109 """
110 for key, value in newdict.items():
111 if isinstance(value, Mapping):
112 origdict[key] = deep_update(origdict.get(key, {}), value)
113
114 else:
115 origdict[key] = value
116
117 return origdict
118
119
120def merge_ordered_dicts(dicts):
121 """Merges nested OrderedDict objects resulting from reading a hjson file.
122 Later input dicts overrides earlier dicts for plain values.
123 If any value is "!delete!", the existing value will be removed from its parent.
124 Arrays will be appended. If the first entry of an array is "!reset!", the contents of the array will be cleared and replaced with RHS.
125 Dictionaries will be recursively merged. If any entry is "!reset!", the contents of the dictionary will be cleared and replaced with RHS.
126 """
127 result = OrderedDict()
128
129 def add_entry(target, k, v):
130 if k in target and isinstance(v, (OrderedDict, dict)):
131 if "!reset!" in v:
132 target[k] = v
133 else:
134 target[k] = merge_ordered_dicts([target[k], v])
135 if "!reset!" in target[k]:
136 del target[k]["!reset!"]
137 elif k in target and isinstance(v, list):
138 if v[0] == '!reset!':
139 target[k] = v[1:]
140 else:
141 target[k] = target[k] + v
142 elif v == "!delete!" and isinstance(target, (OrderedDict, dict)):
143 del target[k]
144 else:
145 target[k] = v
146
147 for d in dicts:
148 for (k, v) in d.items():
149 add_entry(result, k, v)
150
151 return result
diff --git a/lib/python/qmk/keyboard.py b/lib/python/qmk/keyboard.py
new file mode 100644
index 0000000000..e8534492c9
--- /dev/null
+++ b/lib/python/qmk/keyboard.py
@@ -0,0 +1,401 @@
1"""Functions that help us work with keyboards.
2"""
3from array import array
4from functools import lru_cache
5from math import ceil
6from pathlib import Path
7import os
8from glob import glob
9
10import qmk.path
11from qmk.c_parse import parse_config_h_file
12from qmk.json_schema import json_load
13from qmk.makefile import parse_rules_mk_file
14
15BOX_DRAWING_CHARACTERS = {
16 "unicode": {
17 "tl": "┌",
18 "tr": "┐",
19 "bl": "└",
20 "br": "┘",
21 "v": "│",
22 "h": "─",
23 },
24 "ascii": {
25 "tl": " ",
26 "tr": " ",
27 "bl": "|",
28 "br": "|",
29 "v": "|",
30 "h": "_",
31 },
32}
33ENC_DRAWING_CHARACTERS = {
34 "unicode": {
35 "tl": "╭",
36 "tr": "╮",
37 "bl": "╰",
38 "br": "╯",
39 "vl": "▲",
40 "vr": "▼",
41 "v": "│",
42 "h": "─",
43 },
44 "ascii": {
45 "tl": " ",
46 "tr": " ",
47 "bl": "\\",
48 "br": "/",
49 "v": "|",
50 "vl": "/",
51 "vr": "\\",
52 "h": "_",
53 },
54}
55
56
57class AllKeyboards:
58 """Represents all keyboards.
59 """
60 def __str__(self):
61 return 'all'
62
63 def __repr__(self):
64 return 'all'
65
66 def __eq__(self, other):
67 return isinstance(other, AllKeyboards)
68
69
70base_path = os.path.join(os.getcwd(), "keyboards") + os.path.sep
71
72
73@lru_cache(maxsize=1)
74def keyboard_alias_definitions():
75 return json_load(Path('data/mappings/keyboard_aliases.hjson'))
76
77
78def is_all_keyboards(keyboard):
79 """Returns True if the keyboard is an AllKeyboards object.
80 """
81 if isinstance(keyboard, str):
82 return (keyboard == 'all')
83 return isinstance(keyboard, AllKeyboards)
84
85
86def find_keyboard_from_dir():
87 """Returns a keyboard name based on the user's current directory.
88 """
89 relative_cwd = qmk.path.under_qmk_userspace()
90 if not relative_cwd:
91 relative_cwd = qmk.path.under_qmk_firmware()
92
93 if relative_cwd and len(relative_cwd.parts) > 1 and relative_cwd.parts[0] == 'keyboards':
94 # Attempt to extract the keyboard name from the current directory
95 current_path = Path('/'.join(relative_cwd.parts[1:]))
96
97 if 'keymaps' in current_path.parts:
98 # Strip current_path of anything after `keymaps`
99 keymap_index = len(current_path.parts) - current_path.parts.index('keymaps') - 1
100 current_path = current_path.parents[keymap_index]
101
102 if qmk.path.is_keyboard(current_path):
103 return str(current_path)
104
105
106def find_readme(keyboard):
107 """Returns the readme for this keyboard.
108 """
109 cur_dir = qmk.path.keyboard(keyboard)
110 keyboards_dir = Path('keyboards')
111 while not (cur_dir / 'readme.md').exists():
112 if cur_dir == keyboards_dir:
113 return None
114 cur_dir = cur_dir.parent
115
116 return cur_dir / 'readme.md'
117
118
119def keyboard_folder(keyboard):
120 """Returns the actual keyboard folder.
121
122 This checks aliases to resolve the actual path for a keyboard.
123 """
124 aliases = keyboard_alias_definitions()
125
126 while keyboard in aliases:
127 last_keyboard = keyboard
128 keyboard = aliases[keyboard].get('target', keyboard)
129 if keyboard == last_keyboard:
130 break
131
132 if not qmk.path.is_keyboard(keyboard):
133 raise ValueError(f'Invalid keyboard: {keyboard}')
134
135 return keyboard
136
137
138def keyboard_aliases(keyboard):
139 """Returns the list of aliases for the supplied keyboard.
140
141 Includes the keyboard itself.
142 """
143 aliases = json_load(Path('data/mappings/keyboard_aliases.hjson'))
144
145 if keyboard in aliases:
146 keyboard = aliases[keyboard].get('target', keyboard)
147
148 keyboards = set(filter(lambda k: aliases[k].get('target', '') == keyboard, aliases.keys()))
149 keyboards.add(keyboard)
150 keyboards = list(sorted(keyboards))
151 return keyboards
152
153
154def keyboard_folder_or_all(keyboard):
155 """Returns the actual keyboard folder.
156
157 This checks aliases to resolve the actual path for a keyboard.
158 If the supplied argument is "all", it returns an AllKeyboards object.
159 """
160 if keyboard == 'all':
161 return AllKeyboards()
162
163 return keyboard_folder(keyboard)
164
165
166def _find_name(path):
167 """Determine the keyboard name by stripping off the base_path and filename.
168 """
169 return path.replace(base_path, "").rsplit(os.path.sep, 1)[0]
170
171
172def keyboard_completer(prefix, action, parser, parsed_args):
173 """Returns a list of keyboards for tab completion.
174 """
175 return list_keyboards()
176
177
178@lru_cache(maxsize=None)
179def list_keyboards():
180 """Returns a list of all keyboards.
181 """
182 # We avoid pathlib here because this is performance critical code.
183 kb_wildcard = os.path.join(base_path, "**", 'keyboard.json')
184 paths = [path for path in glob(kb_wildcard, recursive=True) if os.path.sep + 'keymaps' + os.path.sep not in path]
185
186 found = map(_find_name, paths)
187
188 # Convert to posix paths for consistency
189 found = map(lambda x: str(Path(x).as_posix()), found)
190
191 return sorted(set(found))
192
193
194def config_h(keyboard):
195 """Parses all the config.h files for a keyboard.
196
197 Args:
198 keyboard: name of the keyboard
199
200 Returns:
201 a dictionary representing the content of the entire config.h tree for a keyboard
202 """
203 config = {}
204 cur_dir = Path('keyboards')
205 keyboard = Path(keyboard)
206
207 for dir in keyboard.parts:
208 cur_dir = cur_dir / dir
209 config = {**config, **parse_config_h_file(cur_dir / 'config.h')}
210
211 return config
212
213
214def rules_mk(keyboard):
215 """Get a rules.mk for a keyboard
216
217 Args:
218 keyboard: name of the keyboard
219
220 Returns:
221 a dictionary representing the content of the entire rules.mk tree for a keyboard
222 """
223 cur_dir = Path('keyboards')
224 keyboard = Path(keyboard)
225 rules = parse_rules_mk_file(cur_dir / keyboard / 'rules.mk')
226
227 for i, dir in enumerate(keyboard.parts):
228 cur_dir = cur_dir / dir
229 rules = parse_rules_mk_file(cur_dir / 'rules.mk', rules)
230
231 return rules
232
233
234def render_layout(layout_data, render_ascii, key_labels=None):
235 """Renders a single layout.
236 """
237 textpad = [array('u', ' ' * 200) for x in range(100)]
238 style = 'ascii' if render_ascii else 'unicode'
239
240 for key in layout_data:
241 x = key.get('x', 0)
242 y = key.get('y', 0)
243 w = key.get('w', 1)
244 h = key.get('h', 1)
245
246 if key_labels:
247 label = key_labels.pop(0)
248 if label.startswith('KC_'):
249 label = label[3:]
250 else:
251 label = key.get('label', '')
252
253 if 'encoder' in key:
254 render_encoder(textpad, x, y, w, h, label, style)
255 elif x >= 0.25 and w == 1.25 and h == 2:
256 render_key_isoenter(textpad, x, y, w, h, label, style)
257 elif w == 1.5 and h == 2:
258 render_key_baenter(textpad, x, y, w, h, label, style)
259 else:
260 render_key_rect(textpad, x, y, w, h, label, style)
261
262 lines = []
263 for line in textpad:
264 if line.tounicode().strip():
265 lines.append(line.tounicode().rstrip())
266
267 return '\n'.join(lines)
268
269
270def render_layouts(info_json, render_ascii):
271 """Renders all the layouts from an `info_json` structure.
272 """
273 layouts = {}
274
275 for layout in info_json['layouts']:
276 layout_data = info_json['layouts'][layout]['layout']
277 layouts[layout] = render_layout(layout_data, render_ascii)
278
279 return layouts
280
281
282def render_key_rect(textpad, x, y, w, h, label, style):
283 box_chars = BOX_DRAWING_CHARACTERS[style]
284 x = ceil(x * 4)
285 y = ceil(y * 3)
286 w = ceil(w * 4)
287 h = ceil(h * 3)
288
289 label_len = w - 2
290 label_leftover = label_len - len(label)
291
292 if len(label) > label_len:
293 label = label[:label_len]
294
295 label_blank = ' ' * label_len
296 label_border = box_chars['h'] * label_len
297 label_middle = label + ' ' * label_leftover
298
299 top_line = array('u', box_chars['tl'] + label_border + box_chars['tr'])
300 lab_line = array('u', box_chars['v'] + label_middle + box_chars['v'])
301 mid_line = array('u', box_chars['v'] + label_blank + box_chars['v'])
302 bot_line = array('u', box_chars['bl'] + label_border + box_chars['br'])
303
304 textpad[y][x:x + w] = top_line
305 textpad[y + 1][x:x + w] = lab_line
306 for i in range(h - 3):
307 textpad[y + i + 2][x:x + w] = mid_line
308 textpad[y + h - 1][x:x + w] = bot_line
309
310
311def render_key_isoenter(textpad, x, y, w, h, label, style):
312 box_chars = BOX_DRAWING_CHARACTERS[style]
313 x = ceil(x * 4)
314 y = ceil(y * 3)
315 w = ceil(w * 4)
316 h = ceil(h * 3)
317
318 label_len = w - 1
319 label_leftover = label_len - len(label)
320
321 if len(label) > label_len:
322 label = label[:label_len]
323
324 label_blank = ' ' * (label_len - 1)
325 label_border_top = box_chars['h'] * label_len
326 label_border_bottom = box_chars['h'] * (label_len - 1)
327 label_middle = label + ' ' * label_leftover
328
329 top_line = array('u', box_chars['tl'] + label_border_top + box_chars['tr'])
330 lab_line = array('u', box_chars['v'] + label_middle + box_chars['v'])
331 crn_line = array('u', box_chars['bl'] + box_chars['tr'] + label_blank + box_chars['v'])
332 mid_line = array('u', box_chars['v'] + label_blank + box_chars['v'])
333 bot_line = array('u', box_chars['bl'] + label_border_bottom + box_chars['br'])
334
335 textpad[y][x - 1:x + w] = top_line
336 textpad[y + 1][x - 1:x + w] = lab_line
337 textpad[y + 2][x - 1:x + w] = crn_line
338 textpad[y + 3][x:x + w] = mid_line
339 textpad[y + 4][x:x + w] = mid_line
340 textpad[y + 5][x:x + w] = bot_line
341
342
343def render_key_baenter(textpad, x, y, w, h, label, style):
344 box_chars = BOX_DRAWING_CHARACTERS[style]
345 x = ceil(x * 4)
346 y = ceil(y * 3)
347 w = ceil(w * 4)
348 h = ceil(h * 3)
349
350 label_len = w + 1
351 label_leftover = label_len - len(label)
352
353 if len(label) > label_len:
354 label = label[:label_len]
355
356 label_blank = ' ' * (label_len - 3)
357 label_border_top = box_chars['h'] * (label_len - 3)
358 label_border_bottom = box_chars['h'] * label_len
359 label_middle = label + ' ' * label_leftover
360
361 top_line = array('u', box_chars['tl'] + label_border_top + box_chars['tr'])
362 mid_line = array('u', box_chars['v'] + label_blank + box_chars['v'])
363 crn_line = array('u', box_chars['tl'] + box_chars['h'] + box_chars['h'] + box_chars['br'] + label_blank + box_chars['v'])
364 lab_line = array('u', box_chars['v'] + label_middle + box_chars['v'])
365 bot_line = array('u', box_chars['bl'] + label_border_bottom + box_chars['br'])
366
367 textpad[y][x:x + w] = top_line
368 textpad[y + 1][x:x + w] = mid_line
369 textpad[y + 2][x:x + w] = mid_line
370 textpad[y + 3][x - 3:x + w] = crn_line
371 textpad[y + 4][x - 3:x + w] = lab_line
372 textpad[y + 5][x - 3:x + w] = bot_line
373
374
375def render_encoder(textpad, x, y, w, h, label, style):
376 box_chars = ENC_DRAWING_CHARACTERS[style]
377 x = ceil(x * 4)
378 y = ceil(y * 3)
379 w = ceil(w * 4)
380 h = ceil(h * 3)
381
382 label_len = w - 2
383 label_leftover = label_len - len(label)
384
385 if len(label) > label_len:
386 label = label[:label_len]
387
388 label_blank = ' ' * label_len
389 label_border = box_chars['h'] * label_len
390 label_middle = label + ' ' * label_leftover
391
392 top_line = array('u', box_chars['tl'] + label_border + box_chars['tr'])
393 lab_line = array('u', box_chars['vl'] + label_middle + box_chars['vr'])
394 mid_line = array('u', box_chars['v'] + label_blank + box_chars['v'])
395 bot_line = array('u', box_chars['bl'] + label_border + box_chars['br'])
396
397 textpad[y][x:x + w] = top_line
398 textpad[y + 1][x:x + w] = lab_line
399 for i in range(h - 3):
400 textpad[y + i + 2][x:x + w] = mid_line
401 textpad[y + h - 1][x:x + w] = bot_line
diff --git a/lib/python/qmk/keycodes.py b/lib/python/qmk/keycodes.py
new file mode 100644
index 0000000000..9e4664e5f1
--- /dev/null
+++ b/lib/python/qmk/keycodes.py
@@ -0,0 +1,118 @@
1from pathlib import Path
2
3from qmk.json_schema import merge_ordered_dicts, deep_update, json_load, validate
4
5CONSTANTS_PATH = Path('data/constants/')
6KEYCODES_PATH = CONSTANTS_PATH / 'keycodes'
7EXTRAS_PATH = KEYCODES_PATH / 'extras'
8
9
10def _find_versions(path, prefix):
11 ret = []
12 for file in path.glob(f'{prefix}_[0-9].[0-9].[0-9].hjson'):
13 ret.append(file.stem.split('_')[-1])
14
15 ret.sort(reverse=True)
16 return ret
17
18
19def _potential_search_versions(version, lang=None):
20 versions = list_versions(lang)
21 versions.reverse()
22
23 loc = versions.index(version) + 1
24
25 return versions[:loc]
26
27
28def _search_path(lang=None):
29 return EXTRAS_PATH if lang else KEYCODES_PATH
30
31
32def _search_prefix(lang=None):
33 return f'keycodes_{lang}' if lang else 'keycodes'
34
35
36def _locate_files(path, prefix, versions):
37 # collate files by fragment "type"
38 files = {'_': []}
39 for version in versions:
40 files['_'].append(path / f'{prefix}_{version}.hjson')
41
42 for file in path.glob(f'{prefix}_{version}_*.hjson'):
43 fragment = file.stem.replace(f'{prefix}_{version}_', '')
44 if fragment not in files:
45 files[fragment] = []
46 files[fragment].append(file)
47
48 return files
49
50
51def _process_files(files):
52 # allow override within types of fragments - but not globally
53 spec = {}
54 for category in files.values():
55 specs = []
56 for file in category:
57 specs.append(json_load(file))
58
59 deep_update(spec, merge_ordered_dicts(specs))
60
61 return spec
62
63
64def _validate(spec):
65 # first throw it to the jsonschema
66 validate(spec, 'qmk.keycodes.v1')
67
68 # no duplicate keycodes
69 keycodes = []
70 for value in spec['keycodes'].values():
71 keycodes.append(value['key'])
72 keycodes.extend(value.get('aliases', []))
73 duplicates = set([x for x in keycodes if keycodes.count(x) > 1])
74 if duplicates:
75 raise ValueError(f'Keycode spec contains duplicate keycodes! ({",".join(duplicates)})')
76
77
78def load_spec(version, lang=None):
79 """Build keycode data from the requested spec file
80 """
81 if version == 'latest':
82 version = list_versions(lang)[0]
83
84 path = _search_path(lang)
85 prefix = _search_prefix(lang)
86 versions = _potential_search_versions(version, lang)
87
88 # Load bases + any fragments
89 spec = _process_files(_locate_files(path, prefix, versions))
90
91 # Sort?
92 spec['version'] = version
93 spec['keycodes'] = dict(sorted(spec.get('keycodes', {}).items()))
94 spec['ranges'] = dict(sorted(spec.get('ranges', {}).items()))
95
96 # Validate?
97 _validate(spec)
98
99 return spec
100
101
102def list_versions(lang=None):
103 """Return available versions - sorted newest first
104 """
105 path = _search_path(lang)
106 prefix = _search_prefix(lang)
107
108 return _find_versions(path, prefix)
109
110
111def list_languages():
112 """Return available languages
113 """
114 ret = set()
115 for file in EXTRAS_PATH.glob('keycodes_*_[0-9].[0-9].[0-9].hjson'):
116 ret.add(file.stem.split('_')[1])
117
118 return ret
diff --git a/lib/python/qmk/keymap.py b/lib/python/qmk/keymap.py
new file mode 100644
index 0000000000..0ac04f6f73
--- /dev/null
+++ b/lib/python/qmk/keymap.py
@@ -0,0 +1,681 @@
1"""Functions that help you work with QMK keymaps.
2"""
3import json
4import sys
5from pathlib import Path
6from subprocess import DEVNULL
7
8import argcomplete
9from milc import cli
10from pygments.lexers.c_cpp import CLexer
11from pygments.token import Token
12from pygments import lex
13
14import qmk.path
15from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
16from qmk.keyboard import find_keyboard_from_dir, keyboard_folder, keyboard_aliases
17from qmk.errors import CppError
18from qmk.info import info_json
19
20# The `keymap.c` template to use when a keyboard doesn't have its own
21DEFAULT_KEYMAP_C = """#include QMK_KEYBOARD_H
22#if __has_include("keymap.h")
23# include "keymap.h"
24#endif
25__INCLUDES__
26
27/* THIS FILE WAS GENERATED!
28 *
29 * This file was generated by qmk json2c. You may or may not want to
30 * edit it directly.
31 */
32
33__KEYMAP_GOES_HERE__
34__ENCODER_MAP_GOES_HERE__
35__DIP_SWITCH_MAP_GOES_HERE__
36__MACRO_OUTPUT_GOES_HERE__
37
38#ifdef OTHER_KEYMAP_C
39# include OTHER_KEYMAP_C
40#endif // OTHER_KEYMAP_C
41"""
42
43
44def _generate_keymap_table(keymap_json):
45 lines = ['const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {']
46 for layer_num, layer in enumerate(keymap_json['layers']):
47 if layer_num != 0:
48 lines[-1] = lines[-1] + ','
49 layer = map(_strip_any, layer)
50 layer_keys = ', '.join(layer)
51 lines.append(' [%s] = %s(%s)' % (layer_num, keymap_json['layout'], layer_keys))
52 lines.append('};')
53 return lines
54
55
56def _generate_encodermap_table(keymap_json):
57 lines = [
58 '#if defined(ENCODER_ENABLE) && defined(ENCODER_MAP_ENABLE)',
59 'const uint16_t PROGMEM encoder_map[][NUM_ENCODERS][NUM_DIRECTIONS] = {',
60 ]
61 for layer_num, layer in enumerate(keymap_json['encoders']):
62 if layer_num != 0:
63 lines[-1] = lines[-1] + ','
64 encoder_keycode_txt = ', '.join([f'ENCODER_CCW_CW({_strip_any(e["ccw"])}, {_strip_any(e["cw"])})' for e in layer])
65 lines.append(' [%s] = {%s}' % (layer_num, encoder_keycode_txt))
66 lines.extend(['};', '#endif // defined(ENCODER_ENABLE) && defined(ENCODER_MAP_ENABLE)'])
67 return lines
68
69
70def _generate_dipswitchmap_table(keymap_json):
71 lines = [
72 '#if defined(DIP_SWITCH_ENABLE) && defined(DIP_SWITCH_MAP_ENABLE)',
73 'const uint16_t PROGMEM dip_switch_map[NUM_DIP_SWITCHES][NUM_DIP_STATES] = {',
74 ]
75 for index, switch in enumerate(keymap_json['dip_switches']):
76 if index != 0:
77 lines[-1] = lines[-1] + ','
78 lines.append(f' DIP_SWITCH_OFF_ON({_strip_any(switch["off"])}, {_strip_any(switch["on"])})')
79 lines.extend(['};', '#endif // defined(DIP_SWITCH_ENABLE) && defined(DIP_SWITCH_MAP_ENABLE)'])
80 return lines
81
82
83def _generate_macros_function(keymap_json):
84 macro_txt = [
85 'bool process_record_user(uint16_t keycode, keyrecord_t *record) {',
86 ' if (record->event.pressed) {',
87 ' switch (keycode) {',
88 ]
89
90 for i, macro_array in enumerate(keymap_json['macros']):
91 macro = []
92
93 for macro_fragment in macro_array:
94 if isinstance(macro_fragment, str):
95 macro_fragment = macro_fragment.replace('\\', '\\\\')
96 macro_fragment = macro_fragment.replace('\r\n', r'\n')
97 macro_fragment = macro_fragment.replace('\n', r'\n')
98 macro_fragment = macro_fragment.replace('\r', r'\n')
99 macro_fragment = macro_fragment.replace('\t', r'\t')
100 macro_fragment = macro_fragment.replace('"', r'\"')
101
102 macro.append(f'"{macro_fragment}"')
103
104 elif isinstance(macro_fragment, dict):
105 newstring = []
106
107 if macro_fragment['action'] == 'delay':
108 newstring.append(f"SS_DELAY({macro_fragment['duration']})")
109
110 elif macro_fragment['action'] == 'beep':
111 newstring.append(r'"\a"')
112
113 elif macro_fragment['action'] == 'tap' and len(macro_fragment['keycodes']) > 1:
114 last_keycode = macro_fragment['keycodes'].pop()
115
116 for keycode in macro_fragment['keycodes']:
117 newstring.append(f'SS_DOWN(X_{keycode})')
118
119 newstring.append(f'SS_TAP(X_{last_keycode})')
120
121 for keycode in reversed(macro_fragment['keycodes']):
122 newstring.append(f'SS_UP(X_{keycode})')
123
124 else:
125 for keycode in macro_fragment['keycodes']:
126 newstring.append(f"SS_{macro_fragment['action'].upper()}(X_{keycode})")
127
128 macro.append(''.join(newstring))
129
130 new_macro = "".join(macro)
131 new_macro = new_macro.replace('""', '')
132 macro_txt.append(f' case QK_MACRO_{i}:')
133 macro_txt.append(f' SEND_STRING({new_macro});')
134 macro_txt.append(' return false;')
135
136 macro_txt.append(' }')
137 macro_txt.append(' }')
138 macro_txt.append('\n return true;')
139 macro_txt.append('};')
140 macro_txt.append('')
141 return macro_txt
142
143
144def _strip_any(keycode):
145 """Remove ANY() from a keycode.
146 """
147 if keycode.startswith('ANY(') and keycode.endswith(')'):
148 keycode = keycode[4:-1]
149
150 return keycode
151
152
153def find_keymap_from_dir(*args):
154 """Returns `(keymap_name, source)` for the directory provided (or cwd if not specified).
155 """
156 def _impl_find_keymap_from_dir(relative_path):
157 if relative_path and len(relative_path.parts) > 1:
158 # If we're in `qmk_firmware/keyboards` and `keymaps` is in our path, try to find the keyboard name.
159 if relative_path.parts[0] == 'keyboards' and 'keymaps' in relative_path.parts:
160 current_path = Path('/'.join(relative_path.parts[1:])) # Strip 'keyboards' from the front
161
162 if 'keymaps' in current_path.parts and current_path.name != 'keymaps':
163 while current_path.parent.name != 'keymaps':
164 current_path = current_path.parent
165
166 return current_path.name, 'keymap_directory'
167
168 # If we're in `qmk_firmware/layouts` guess the name from the community keymap they're in
169 elif relative_path.parts[0] == 'layouts' and is_keymap_dir(relative_path):
170 return relative_path.name, 'layouts_directory'
171
172 # If we're in `qmk_firmware/users` guess the name from the userspace they're in
173 elif relative_path.parts[0] == 'users':
174 # Guess the keymap name based on which userspace they're in
175 return relative_path.parts[1], 'users_directory'
176 return None, None
177
178 if HAS_QMK_USERSPACE:
179 name, source = _impl_find_keymap_from_dir(qmk.path.under_qmk_userspace(*args))
180 if name and source:
181 return name, source
182
183 name, source = _impl_find_keymap_from_dir(qmk.path.under_qmk_firmware(*args))
184 if name and source:
185 return name, source
186
187 return (None, None)
188
189
190def keymap_completer(prefix, action, parser, parsed_args):
191 """Returns a list of keymaps for tab completion.
192 """
193 try:
194 if parsed_args.keyboard:
195 return list_keymaps(parsed_args.keyboard)
196
197 keyboard = find_keyboard_from_dir()
198
199 if keyboard:
200 return list_keymaps(keyboard)
201
202 except Exception as e:
203 argcomplete.warn(f'Error: {e.__class__.__name__}: {str(e)}')
204 return []
205
206 return []
207
208
209def is_keymap_dir(keymap, c=True, json=True, additional_files=None):
210 """Return True if Path object `keymap` has a keymap file inside.
211
212 Args:
213 keymap
214 A Path() object for the keymap directory you want to check.
215
216 c
217 When true include `keymap.c` keymaps.
218
219 json
220 When true include `keymap.json` keymaps.
221
222 additional_files
223 A sequence of additional filenames to check against to determine if a directory is a keymap. All files must exist for a match to happen. For example, if you want to match a C keymap with both a `config.h` and `rules.mk` file: `is_keymap_dir(keymap_dir, json=False, additional_files=['config.h', 'rules.mk'])`
224 """
225 files = []
226
227 if c:
228 files.append('keymap.c')
229
230 if json:
231 files.append('keymap.json')
232
233 for file in files:
234 if (keymap / file).is_file():
235 if additional_files:
236 for additional_file in additional_files:
237 if not (keymap / additional_file).is_file():
238 return False
239
240 return True
241
242
243def generate_json(keymap, keyboard, layout, layers, macros=None):
244 """Returns a `keymap.json` for the specified keyboard, layout, and layers.
245
246 Args:
247 keymap
248 A name for this keymap.
249
250 keyboard
251 The name of the keyboard.
252
253 layout
254 The LAYOUT macro this keymap uses.
255
256 layers
257 An array of arrays describing the keymap. Each item in the inner array should be a string that is a valid QMK keycode.
258
259 macros
260 A sequence of strings containing macros to implement for this keyboard.
261 """
262 new_keymap = {'keyboard': keyboard}
263 new_keymap['keymap'] = keymap
264 new_keymap['layout'] = layout
265 new_keymap['layers'] = layers
266 if macros:
267 new_keymap['macros'] = macros
268
269 return new_keymap
270
271
272def generate_c(keymap_json):
273 """Returns a `keymap.c`.
274
275 `keymap_json` is a dictionary with the following keys:
276
277 keyboard
278 The name of the keyboard
279
280 layout
281 The LAYOUT macro this keymap uses.
282
283 layers
284 An array of arrays describing the keymap. Each item in the inner array should be a string that is a valid QMK keycode.
285
286 macros
287 A sequence of strings containing macros to implement for this keyboard.
288 """
289 new_keymap = DEFAULT_KEYMAP_C
290
291 keymap = ''
292 if 'layers' in keymap_json and keymap_json['layers'] is not None:
293 layer_txt = _generate_keymap_table(keymap_json)
294 keymap = '\n'.join(layer_txt)
295 new_keymap = new_keymap.replace('__KEYMAP_GOES_HERE__', keymap)
296
297 encodermap = ''
298 if 'encoders' in keymap_json and keymap_json['encoders'] is not None:
299 encoder_txt = _generate_encodermap_table(keymap_json)
300 encodermap = '\n'.join(encoder_txt)
301 new_keymap = new_keymap.replace('__ENCODER_MAP_GOES_HERE__', encodermap)
302
303 dipswitchmap = ''
304 if 'dip_switches' in keymap_json and keymap_json['dip_switches'] is not None:
305 dip_txt = _generate_dipswitchmap_table(keymap_json)
306 dipswitchmap = '\n'.join(dip_txt)
307 new_keymap = new_keymap.replace('__DIP_SWITCH_MAP_GOES_HERE__', dipswitchmap)
308
309 macros = ''
310 if 'macros' in keymap_json and keymap_json['macros'] is not None:
311 macro_txt = _generate_macros_function(keymap_json)
312 macros = '\n'.join(macro_txt)
313 new_keymap = new_keymap.replace('__MACRO_OUTPUT_GOES_HERE__', macros)
314
315 hostlang = ''
316 if 'host_language' in keymap_json and keymap_json['host_language'] is not None:
317 hostlang = f'#include "keymap_{keymap_json["host_language"]}.h"\n#include "sendstring_{keymap_json["host_language"]}.h"\n'
318 new_keymap = new_keymap.replace('__INCLUDES__', hostlang)
319
320 return new_keymap
321
322
323def write_file(keymap_filename, keymap_content):
324 keymap_filename.parent.mkdir(parents=True, exist_ok=True)
325 keymap_filename.write_text(keymap_content)
326
327 cli.log.info('Wrote keymap to {fg_cyan}%s', keymap_filename)
328
329 return keymap_filename
330
331
332def write_json(keyboard, keymap, layout, layers, macros=None):
333 """Generate the `keymap.json` and write it to disk.
334
335 Returns the filename written to.
336
337 Args:
338 keyboard
339 The name of the keyboard
340
341 keymap
342 The name of the keymap
343
344 layout
345 The LAYOUT macro this keymap uses.
346
347 layers
348 An array of arrays describing the keymap. Each item in the inner array should be a string that is a valid QMK keycode.
349 """
350 keymap_json = generate_json(keyboard, keymap, layout, layers, macros=None)
351 keymap_content = json.dumps(keymap_json)
352 keymap_file = qmk.path.keymaps(keyboard)[0] / keymap / 'keymap.json'
353
354 return write_file(keymap_file, keymap_content)
355
356
357def locate_keymap(keyboard, keymap, force_layout=None):
358 """Returns the path to a keymap for a specific keyboard.
359 """
360 if not qmk.path.is_keyboard(keyboard):
361 raise KeyError('Invalid keyboard: ' + repr(keyboard))
362
363 # Check the keyboard folder first, last match wins
364 keymap_path = ''
365
366 search_conf = {QMK_FIRMWARE: [keyboard_folder(keyboard)]}
367 if HAS_QMK_USERSPACE:
368 # When we've got userspace, check there _last_ as we want them to override anything in the main repo.
369 # We also want to search for any aliases as QMK's folder structure may have changed, with an alias, but the user
370 # hasn't updated their keymap location yet.
371 search_conf[QMK_USERSPACE] = list(set([keyboard_folder(keyboard), *keyboard_aliases(keyboard)]))
372
373 for search_dir, keyboard_dirs in search_conf.items():
374 for keyboard_dir in keyboard_dirs:
375 checked_dirs = ''
376 for folder_name in keyboard_dir.split('/'):
377 if checked_dirs:
378 checked_dirs = '/'.join((checked_dirs, folder_name))
379 else:
380 checked_dirs = folder_name
381
382 keymap_dir = Path(search_dir) / Path('keyboards') / checked_dirs / 'keymaps'
383
384 if (keymap_dir / keymap / 'keymap.c').exists():
385 keymap_path = keymap_dir / keymap / 'keymap.c'
386 if (keymap_dir / keymap / 'keymap.json').exists():
387 keymap_path = keymap_dir / keymap / 'keymap.json'
388
389 if keymap_path:
390 return keymap_path
391
392 # Check community layouts as a fallback
393 info = info_json(keyboard, force_layout=force_layout)
394
395 community_parents = list(Path('layouts').glob('*/'))
396 if HAS_QMK_USERSPACE and (Path(QMK_USERSPACE) / "layouts").exists():
397 community_parents.append(Path(QMK_USERSPACE) / "layouts")
398
399 for community_parent in community_parents:
400 for layout in info.get("community_layouts", []):
401 community_layout = community_parent / layout / keymap
402 if community_layout.exists():
403 if (community_layout / 'keymap.json').exists():
404 return community_layout / 'keymap.json'
405 if (community_layout / 'keymap.c').exists():
406 return community_layout / 'keymap.c'
407
408
409def is_keymap_target(keyboard, keymap):
410 if keymap == 'all':
411 return True
412
413 if locate_keymap(keyboard, keymap):
414 return True
415
416 return False
417
418
419def list_keymaps(keyboard, c=True, json=True, additional_files=None, fullpath=False, include_userspace=True):
420 """List the available keymaps for a keyboard.
421
422 Args:
423 keyboard
424 The keyboards full name with vendor and revision if necessary, example: clueboard/66/rev3
425
426 c
427 When true include `keymap.c` keymaps.
428
429 json
430 When true include `keymap.json` keymaps.
431
432 additional_files
433 A sequence of additional filenames to check against to determine if a directory is a keymap. All files must exist for a match to happen. For example, if you want to match a C keymap with both a `config.h` and `rules.mk` file: `is_keymap_dir(keymap_dir, json=False, additional_files=['config.h', 'rules.mk'])`
434
435 fullpath
436 When set to True the full path of the keymap relative to the `qmk_firmware` root will be provided.
437
438 include_userspace
439 When set to True, also search userspace for available keymaps
440
441 Returns:
442 a sorted list of valid keymap names.
443 """
444 names = set()
445
446 has_userspace = HAS_QMK_USERSPACE and include_userspace
447
448 # walk up the directory tree until keyboards_dir
449 # and collect all directories' name with keymap.c file in it
450 for search_dir in [QMK_FIRMWARE, QMK_USERSPACE] if has_userspace else [QMK_FIRMWARE]:
451 keyboards_dir = search_dir / Path('keyboards')
452 kb_path = keyboards_dir / keyboard
453
454 while kb_path != keyboards_dir:
455 keymaps_dir = kb_path / "keymaps"
456 if keymaps_dir.is_dir():
457 for keymap in keymaps_dir.iterdir():
458 if is_keymap_dir(keymap, c, json, additional_files):
459 keymap = keymap if fullpath else keymap.name
460 names.add(keymap)
461
462 kb_path = kb_path.parent
463
464 # Check community layouts as a fallback
465 info = info_json(keyboard)
466
467 community_parents = list(Path('layouts').glob('*/'))
468 if has_userspace and (Path(QMK_USERSPACE) / "layouts").exists():
469 community_parents.append(Path(QMK_USERSPACE) / "layouts")
470
471 for community_parent in community_parents:
472 for layout in info.get("community_layouts", []):
473 cl_path = community_parent / layout
474 if cl_path.is_dir():
475 for keymap in cl_path.iterdir():
476 if is_keymap_dir(keymap, c, json, additional_files):
477 keymap = keymap if fullpath else keymap.name
478 names.add(keymap)
479
480 return sorted(names)
481
482
483def _c_preprocess(path, stdin=DEVNULL):
484 """ Run a file through the C pre-processor
485
486 Args:
487 path: path of the keymap.c file (set None to use stdin)
488 stdin: stdin pipe (e.g. sys.stdin)
489
490 Returns:
491 the stdout of the pre-processor
492 """
493 cmd = ['cpp', str(path)] if path else ['cpp']
494 pre_processed_keymap = cli.run(cmd, stdin=stdin)
495 if 'fatal error' in pre_processed_keymap.stderr:
496 for line in pre_processed_keymap.stderr.split('\n'):
497 if 'fatal error' in line:
498 raise (CppError(line))
499 return pre_processed_keymap.stdout
500
501
502def _get_layers(keymap): # noqa C901 : until someone has a good idea how to simplify/split up this code
503 """ Find the layers in a keymap.c file.
504
505 Args:
506 keymap: the content of the keymap.c file
507
508 Returns:
509 a dictionary containing the parsed keymap
510 """
511 layers = list()
512 opening_braces = '({['
513 closing_braces = ')}]'
514 keymap_certainty = brace_depth = 0
515 is_keymap = is_layer = is_adv_kc = False
516 layer = dict(name=False, layout=False, keycodes=list())
517 for line in lex(keymap, CLexer()):
518 if line[0] is Token.Name:
519 if is_keymap:
520 # If we are inside the keymap array
521 # we know the keymap's name and the layout macro will come,
522 # followed by the keycodes
523 if not layer['name']:
524 if line[1].startswith('LAYOUT') or line[1].startswith('KEYMAP'):
525 # This can happen if the keymap array only has one layer,
526 # for macropads and such
527 layer['name'] = '0'
528 layer['layout'] = line[1]
529 else:
530 layer['name'] = line[1]
531 elif not layer['layout']:
532 layer['layout'] = line[1]
533 elif is_layer:
534 # If we are inside a layout macro,
535 # collect all keycodes
536 if line[1] == '_______':
537 kc = 'KC_TRNS'
538 elif line[1] == 'XXXXXXX':
539 kc = 'KC_NO'
540 else:
541 kc = line[1]
542 if is_adv_kc:
543 # If we are inside an advanced keycode
544 # collect everything and hope the user
545 # knew what he/she was doing
546 layer['keycodes'][-1] += kc
547 else:
548 layer['keycodes'].append(kc)
549
550 # The keymaps array's signature:
551 # const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS]
552 #
553 # Only if we've found all 6 keywords in this specific order
554 # can we know for sure that we are inside the keymaps array
555 elif line[1] == 'PROGMEM' and keymap_certainty == 2:
556 keymap_certainty = 3
557 elif line[1] == 'keymaps' and keymap_certainty == 3:
558 keymap_certainty = 4
559 elif line[1] == 'MATRIX_ROWS' and keymap_certainty == 4:
560 keymap_certainty = 5
561 elif line[1] == 'MATRIX_COLS' and keymap_certainty == 5:
562 keymap_certainty = 6
563 elif line[0] is Token.Keyword:
564 if line[1] == 'const' and keymap_certainty == 0:
565 keymap_certainty = 1
566 elif line[0] is Token.Keyword.Type:
567 if line[1] == 'uint16_t' and keymap_certainty == 1:
568 keymap_certainty = 2
569 elif line[0] is Token.Punctuation:
570 if line[1] in opening_braces:
571 brace_depth += 1
572 if is_keymap:
573 if is_layer:
574 # We found the beginning of a non-basic keycode
575 is_adv_kc = True
576 layer['keycodes'][-1] += line[1]
577 elif line[1] == '(' and brace_depth == 2:
578 # We found the beginning of a layer
579 is_layer = True
580 elif line[1] == '{' and keymap_certainty == 6:
581 # We found the beginning of the keymaps array
582 is_keymap = True
583 elif line[1] in closing_braces:
584 brace_depth -= 1
585 if is_keymap:
586 if is_adv_kc:
587 layer['keycodes'][-1] += line[1]
588 if brace_depth == 2:
589 # We found the end of a non-basic keycode
590 is_adv_kc = False
591 elif line[1] == ')' and brace_depth == 1:
592 # We found the end of a layer
593 is_layer = False
594 layers.append(layer)
595 layer = dict(name=False, layout=False, keycodes=list())
596 elif line[1] == '}' and brace_depth == 0:
597 # We found the end of the keymaps array
598 is_keymap = False
599 keymap_certainty = 0
600 elif is_adv_kc:
601 # Advanced keycodes can contain other punctuation
602 # e.g.: MT(MOD_LCTL | MOD_LSFT, KC_ESC)
603 layer['keycodes'][-1] += line[1]
604
605 elif line[0] is Token.Literal.Number.Integer and is_keymap and not is_adv_kc:
606 # If the pre-processor finds the 'meaning' of the layer names,
607 # they will be numbers
608 if not layer['name']:
609 layer['name'] = line[1]
610
611 else:
612 # We only care about
613 # operators and such if we
614 # are inside an advanced keycode
615 # e.g.: MT(MOD_LCTL | MOD_LSFT, KC_ESC)
616 if is_adv_kc:
617 layer['keycodes'][-1] += line[1]
618
619 return layers
620
621
622def parse_keymap_c(keymap_file, use_cpp=True):
623 """ Parse a keymap.c file.
624
625 Currently only cares about the keymaps array.
626
627 Args:
628 keymap_file: path of the keymap.c file (or '-' to use stdin)
629
630 use_cpp: if True, pre-process the file with the C pre-processor
631
632 Returns:
633 a dictionary containing the parsed keymap
634 """
635 if not isinstance(keymap_file, (Path, str)) or keymap_file == '-':
636 if use_cpp:
637 keymap_file = _c_preprocess(None, sys.stdin)
638 else:
639 keymap_file = sys.stdin.read()
640 else:
641 if use_cpp:
642 keymap_file = _c_preprocess(keymap_file)
643 else:
644 keymap_file = keymap_file.read_text(encoding='utf-8')
645
646 keymap = dict()
647 keymap['layers'] = _get_layers(keymap_file)
648 return keymap
649
650
651def c2json(keyboard, keymap, keymap_file, use_cpp=True):
652 """ Convert keymap.c to keymap.json
653
654 Args:
655 keyboard: The name of the keyboard
656
657 keymap: The name of the keymap
658
659 layout: The LAYOUT macro this keymap uses.
660
661 keymap_file: path of the keymap.c file
662
663 use_cpp: if True, pre-process the file with the C pre-processor
664
665 Returns:
666 a dictionary in keymap.json format
667 """
668 keymap_json = parse_keymap_c(keymap_file, use_cpp)
669
670 dirty_layers = keymap_json.pop('layers', None)
671 keymap_json['layers'] = list()
672 for layer in dirty_layers:
673 layer.pop('name')
674 layout = layer.pop('layout')
675 if not keymap_json.get('layout', False):
676 keymap_json['layout'] = layout
677 keymap_json['layers'].append(layer.pop('keycodes'))
678
679 keymap_json['keyboard'] = keyboard
680 keymap_json['keymap'] = keymap
681 return keymap_json
diff --git a/lib/python/qmk/makefile.py b/lib/python/qmk/makefile.py
new file mode 100644
index 0000000000..ae95abbf23
--- /dev/null
+++ b/lib/python/qmk/makefile.py
@@ -0,0 +1,51 @@
1""" Functions for working with Makefiles
2"""
3from pathlib import Path
4
5
6def parse_rules_mk_file(file, rules_mk=None):
7 """Turn a rules.mk file into a dictionary.
8
9 Args:
10 file: path to the rules.mk file
11 rules_mk: already parsed rules.mk the new file should be merged with
12
13 Returns:
14 a dictionary with the file's content
15 """
16 if not rules_mk:
17 rules_mk = {}
18
19 file = Path(file)
20 if file.exists():
21 rules_mk_lines = file.read_text(encoding='utf-8').split("\n")
22
23 for line in rules_mk_lines:
24 # Filter out comments
25 if line.strip().startswith("#"):
26 continue
27
28 # Strip in-line comments
29 if '#' in line:
30 line = line[:line.index('#')].strip()
31
32 if '=' in line:
33 # Append
34 if '+=' in line:
35 key, value = line.split('+=', 1)
36 if key.strip() not in rules_mk:
37 rules_mk[key.strip()] = value.strip()
38 else:
39 rules_mk[key.strip()] += ' ' + value.strip()
40 # Set if absent
41 elif "?=" in line:
42 key, value = line.split('?=', 1)
43 if key.strip() not in rules_mk:
44 rules_mk[key.strip()] = value.strip()
45 else:
46 if ":=" in line:
47 line.replace(":", "")
48 key, value = line.split('=', 1)
49 rules_mk[key.strip()] = value.strip()
50
51 return rules_mk
diff --git a/lib/python/qmk/math_ops.py b/lib/python/qmk/math_ops.py
new file mode 100644
index 0000000000..1f14b18f4e
--- /dev/null
+++ b/lib/python/qmk/math_ops.py
@@ -0,0 +1,33 @@
1"""Parse arbitrary math equations in a safe way.
2
3Gratefully copied from https://stackoverflow.com/a/9558001
4"""
5import ast
6import operator as op
7
8# supported operators
9operators = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, ast.Pow: op.pow, ast.BitXor: op.xor, ast.USub: op.neg}
10
11
12def compute(expr):
13 """Parse a mathematical expression and return the answer.
14
15 >>> compute('2^6')
16 4
17 >>> compute('2**6')
18 64
19 >>> compute('1 + 2*3**(4^5) / (6 + -7)')
20 -5.0
21 """
22 return _eval(ast.parse(expr, mode='eval').body)
23
24
25def _eval(node):
26 if isinstance(node, ast.Constant): # <number>
27 return node.value
28 elif isinstance(node, ast.BinOp): # <left> <operator> <right>
29 return operators[type(node.op)](_eval(node.left), _eval(node.right))
30 elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1
31 return operators[type(node.op)](_eval(node.operand))
32 else:
33 raise TypeError(node)
diff --git a/lib/python/qmk/painter.py b/lib/python/qmk/painter.py
new file mode 100644
index 0000000000..1a07f7442c
--- /dev/null
+++ b/lib/python/qmk/painter.py
@@ -0,0 +1,456 @@
1"""Functions that help us work with Quantum Painter's file formats.
2"""
3import datetime
4import math
5import re
6from pathlib import Path
7from string import Template
8from PIL import Image, ImageOps
9
10# The list of valid formats Quantum Painter supports
11valid_formats = {
12 'rgb888': {
13 'image_format': 'IMAGE_FORMAT_RGB888',
14 'bpp': 24,
15 'has_palette': False,
16 'num_colors': 16777216,
17 'image_format_byte': 0x09, # see qp_internal_formats.h
18 },
19 'rgb565': {
20 'image_format': 'IMAGE_FORMAT_RGB565',
21 'bpp': 16,
22 'has_palette': False,
23 'num_colors': 65536,
24 'image_format_byte': 0x08, # see qp_internal_formats.h
25 },
26 'pal256': {
27 'image_format': 'IMAGE_FORMAT_PALETTE',
28 'bpp': 8,
29 'has_palette': True,
30 'num_colors': 256,
31 'image_format_byte': 0x07, # see qp_internal_formats.h
32 },
33 'pal16': {
34 'image_format': 'IMAGE_FORMAT_PALETTE',
35 'bpp': 4,
36 'has_palette': True,
37 'num_colors': 16,
38 'image_format_byte': 0x06, # see qp_internal_formats.h
39 },
40 'pal4': {
41 'image_format': 'IMAGE_FORMAT_PALETTE',
42 'bpp': 2,
43 'has_palette': True,
44 'num_colors': 4,
45 'image_format_byte': 0x05, # see qp_internal_formats.h
46 },
47 'pal2': {
48 'image_format': 'IMAGE_FORMAT_PALETTE',
49 'bpp': 1,
50 'has_palette': True,
51 'num_colors': 2,
52 'image_format_byte': 0x04, # see qp_internal_formats.h
53 },
54 'mono256': {
55 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
56 'bpp': 8,
57 'has_palette': False,
58 'num_colors': 256,
59 'image_format_byte': 0x03, # see qp_internal_formats.h
60 },
61 'mono16': {
62 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
63 'bpp': 4,
64 'has_palette': False,
65 'num_colors': 16,
66 'image_format_byte': 0x02, # see qp_internal_formats.h
67 },
68 'mono4': {
69 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
70 'bpp': 2,
71 'has_palette': False,
72 'num_colors': 4,
73 'image_format_byte': 0x01, # see qp_internal_formats.h
74 },
75 'mono2': {
76 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
77 'bpp': 1,
78 'has_palette': False,
79 'num_colors': 2,
80 'image_format_byte': 0x00, # see qp_internal_formats.h
81 }
82}
83
84
85def _render_text(values):
86 # FIXME: May need more chars with GIFs containing lots of frames (or longer durations)
87 return "|".join([f"{i:4d}" for i in values])
88
89
90def _render_numeration(metadata):
91 return _render_text(range(len(metadata)))
92
93
94def _render_values(metadata, key):
95 return _render_text([i[key] for i in metadata])
96
97
98def _render_image_metadata(metadata):
99 size = metadata.pop(0)
100
101 lines = [
102 "// Image's metadata",
103 "// ----------------",
104 f"// Width: {size['width']}",
105 f"// Height: {size['height']}",
106 ]
107
108 if len(metadata) == 1:
109 lines.append("// Single frame")
110
111 else:
112 lines.extend([
113 f"// Frame: {_render_numeration(metadata)}",
114 f"// Duration(ms): {_render_values(metadata, 'delay')}",
115 f"// Compression: {_render_values(metadata, 'compression')} >> See qp.h, painter_compression_t",
116 f"// Delta: {_render_values(metadata, 'delta')}",
117 ])
118
119 deltas = []
120 for i, v in enumerate(metadata):
121 # Not a delta frame, go to next one
122 if not v["delta"]:
123 continue
124
125 # Unpack rect's coords
126 l, t, r, b = v["delta_rect"]
127
128 delta_px = (r - l) * (b - t)
129 px = size["width"] * size["height"]
130
131 # FIXME: May need need more chars here too
132 deltas.append(f"// Frame {i:3d}: ({l:3d}, {t:3d}) - ({r:3d}, {b:3d}) >> {delta_px:4d}/{px:4d} pixels ({100 * delta_px / px:.2f}%)")
133
134 if deltas:
135 lines.append("// Areas on delta frames")
136 lines.extend(deltas)
137
138 return "\n".join(lines)
139
140
141def command_args_str(cli, command_name):
142 """Given a command name, introspect milc to get the arguments passed in."""
143
144 args = {}
145 max_length = 0
146 for arg_name, was_passed in cli.args_passed[command_name].items():
147 max_length = max(max_length, len(arg_name))
148
149 val = getattr(cli.args, arg_name.replace("-", "_"))
150
151 # do not leak full paths, keep just file name
152 if isinstance(val, Path):
153 val = val.name
154
155 args[arg_name] = val
156
157 return "\n".join(f"// {arg_name.ljust(max_length)} | {val}" for arg_name, val in args.items())
158
159
160def generate_subs(cli, out_bytes, *, font_metadata=None, image_metadata=None, command_name):
161 if font_metadata is not None and image_metadata is not None:
162 raise ValueError("Cant generate subs for font and image at the same time")
163
164 args = command_args_str(cli, command_name)
165
166 subs = {
167 "year": datetime.date.today().strftime("%Y"),
168 "input_file": cli.args.input.name,
169 "sane_name": re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
170 "byte_count": len(out_bytes),
171 "bytes_lines": render_bytes(out_bytes),
172 "format": cli.args.format,
173 "generator_command": command_name.replace("_", "-"),
174 "command_args": args,
175 }
176
177 if font_metadata is not None:
178 subs.update({
179 "generated_type": "font",
180 "var_prefix": "font",
181 # not using triple quotes to avoid extra indentation/weird formatted code
182 "metadata": "\n".join([
183 "// Font's metadata",
184 "// ---------------",
185 f"// Glyphs: {', '.join([i for i in font_metadata['glyphs']])}",
186 ]),
187 })
188
189 elif image_metadata is not None:
190 subs.update({
191 "generated_type": "image",
192 "var_prefix": "gfx",
193 "generator_command": command_name,
194 "metadata": _render_image_metadata(image_metadata),
195 })
196
197 else:
198 raise ValueError("Pass metadata for either an image or a font")
199
200 subs.update({"license": render_license(subs)})
201
202 return subs
203
204
205license_template = """\
206// Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright
207// SPDX-License-Identifier: GPL-2.0-or-later
208
209// This file was auto-generated by `${generator_command}` with arguments:
210${command_args}
211"""
212
213
214def render_license(subs):
215 license_txt = Template(license_template)
216 return license_txt.substitute(subs)
217
218
219header_file_template = """\
220${license}
221#pragma once
222
223#include <qp.h>
224
225extern const uint32_t ${var_prefix}_${sane_name}_length;
226extern const uint8_t ${var_prefix}_${sane_name}[${byte_count}];
227"""
228
229
230def render_header(subs):
231 header_txt = Template(header_file_template)
232 return header_txt.substitute(subs)
233
234
235source_file_template = """\
236${license}
237${metadata}
238
239#include <qp.h>
240
241const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count};
242
243// clang-format off
244const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = {
245${bytes_lines}
246};
247// clang-format on
248"""
249
250
251def render_source(subs):
252 source_txt = Template(source_file_template)
253 return source_txt.substitute(subs)
254
255
256def render_bytes(bytes, newline_after=16):
257 lines = ''
258 for n in range(len(bytes)):
259 if n % newline_after == 0 and n > 0 and n != len(bytes):
260 lines = lines + "\n "
261 elif n == 0:
262 lines = lines + " "
263 lines = lines + " 0x{0:02X},".format(bytes[n])
264 return lines.rstrip()
265
266
267def clean_output(str):
268 str = re.sub(r'\r', '', str)
269 str = re.sub(r'[\n]{3,}', r'\n\n', str)
270 return str
271
272
273def rescale_byte(val, maxval):
274 """Rescales a byte value to the supplied range, i.e. [0,255] -> [0,maxval].
275 """
276 return int(round(val * maxval / 255.0))
277
278
279def convert_requested_format(im, format):
280 """Convert an image to the requested format.
281 """
282
283 # Work out the requested format
284 ncolors = format["num_colors"]
285 image_format = format["image_format"]
286
287 # -- Check if ncolors is valid
288 # Formats accepting several options
289 if image_format in ['IMAGE_FORMAT_GRAYSCALE', 'IMAGE_FORMAT_PALETTE']:
290 valid = [2, 4, 8, 16, 256]
291
292 # Formats expecting a particular number
293 else:
294 # Read number from specs dict, instead of hardcoding
295 for _, fmt in valid_formats.items():
296 if fmt["image_format"] == image_format:
297 # has to be an iterable, to use `in`
298 valid = [fmt["num_colors"]]
299 break
300
301 if ncolors not in valid:
302 raise ValueError(f"Number of colors must be: {', '.join(valid)}.")
303
304 # Work out where we're getting the bytes from
305 if image_format == 'IMAGE_FORMAT_GRAYSCALE':
306 # If mono, convert input to grayscale, then to RGB, then grab the raw bytes corresponding to the intensity of the red channel
307 im = ImageOps.grayscale(im)
308 im = im.convert("RGB")
309 elif image_format == 'IMAGE_FORMAT_PALETTE':
310 # If color, convert input to RGB, palettize based on the supplied number of colors, then get the raw palette bytes
311 im = im.convert("RGB")
312 im = im.convert("P", palette=Image.ADAPTIVE, colors=ncolors)
313 elif image_format in ['IMAGE_FORMAT_RGB565', 'IMAGE_FORMAT_RGB888']:
314 # Convert input to RGB
315 im = im.convert("RGB")
316
317 return im
318
319
320def rgb_to565(r, g, b):
321 msb = ((r >> 3 & 0x1F) << 3) + (g >> 5 & 0x07)
322 lsb = ((g >> 2 & 0x07) << 5) + (b >> 3 & 0x1F)
323 return [msb, lsb]
324
325
326def convert_image_bytes(im, format):
327 """Convert the supplied image to the equivalent bytes required by the QMK firmware.
328 """
329
330 # Work out the requested format
331 ncolors = format["num_colors"]
332 image_format = format["image_format"]
333 shifter = int(math.log2(ncolors))
334 pixels_per_byte = int(8 / math.log2(ncolors))
335 bytes_per_pixel = math.ceil(math.log2(ncolors) / 8)
336 (width, height) = im.size
337 if (pixels_per_byte != 0):
338 expected_byte_count = ((width * height) + (pixels_per_byte - 1)) // pixels_per_byte
339 else:
340 expected_byte_count = width * height * bytes_per_pixel
341
342 if image_format == 'IMAGE_FORMAT_GRAYSCALE':
343 # Take the red channel
344 image_bytes = im.tobytes("raw", "R")
345 image_bytes_len = len(image_bytes)
346
347 # No palette
348 palette = None
349
350 bytearray = []
351 for x in range(expected_byte_count):
352 byte = 0
353 for n in range(pixels_per_byte):
354 byte_offset = x * pixels_per_byte + n
355 if byte_offset < image_bytes_len:
356 # If mono, each input byte is a grayscale [0,255] pixel -- rescale to the range we want then pack together
357 byte = byte | (rescale_byte(image_bytes[byte_offset], ncolors - 1) << int(n * shifter))
358 bytearray.append(byte)
359
360 elif image_format == 'IMAGE_FORMAT_PALETTE':
361 # Convert each pixel to the palette bytes
362 image_bytes = im.tobytes("raw", "P")
363 image_bytes_len = len(image_bytes)
364
365 # Export the palette
366 palette = []
367 pal = im.getpalette()
368 for n in range(0, ncolors * 3, 3):
369 palette.append((pal[n + 0], pal[n + 1], pal[n + 2]))
370
371 bytearray = []
372 for x in range(expected_byte_count):
373 byte = 0
374 for n in range(pixels_per_byte):
375 byte_offset = x * pixels_per_byte + n
376 if byte_offset < image_bytes_len:
377 # If color, each input byte is the index into the color palette -- pack them together
378 byte = byte | ((image_bytes[byte_offset] & (ncolors - 1)) << int(n * shifter))
379 bytearray.append(byte)
380
381 if image_format == 'IMAGE_FORMAT_RGB565':
382 # Take the red, green, and blue channels
383 red = im.tobytes("raw", "R")
384 green = im.tobytes("raw", "G")
385 blue = im.tobytes("raw", "B")
386
387 # No palette
388 palette = None
389
390 bytearray = [byte for r, g, b in zip(red, green, blue) for byte in rgb_to565(r, g, b)]
391
392 if image_format == 'IMAGE_FORMAT_RGB888':
393 # Take the red, green, and blue channels
394 red = im.tobytes("raw", "R")
395 green = im.tobytes("raw", "G")
396 blue = im.tobytes("raw", "B")
397
398 # No palette
399 palette = None
400
401 bytearray = [byte for r, g, b in zip(red, green, blue) for byte in (r, g, b)]
402
403 if len(bytearray) != expected_byte_count:
404 raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}")
405
406 return (palette, bytearray)
407
408
409def compress_bytes_qmk_rle(bytearray):
410 debug_dump = False
411 output = []
412 temp = []
413 repeat = False
414
415 def append_byte(c):
416 if debug_dump:
417 print('Appending byte:', '0x{0:02X}'.format(int(c)), '=', c)
418 output.append(c)
419
420 def append_range(r):
421 append_byte(127 + len(r))
422 if debug_dump:
423 print('Appending {0} byte(s):'.format(len(r)), '[', ', '.join(['{0:02X}'.format(e) for e in r]), ']')
424 output.extend(r)
425
426 for n in range(0, len(bytearray) + 1):
427 end = True if n == len(bytearray) else False
428 if not end:
429 c = bytearray[n]
430 temp.append(c)
431 if len(temp) <= 1:
432 continue
433
434 if debug_dump:
435 print('Temp buffer state {0:3d} bytes:'.format(len(temp)), '[', ', '.join(['{0:02X}'.format(e) for e in temp]), ']')
436
437 if repeat:
438 if temp[-1] != temp[-2]:
439 repeat = False
440 if not repeat or len(temp) == 128 or end:
441 append_byte(len(temp) if end else len(temp) - 1)
442 append_byte(temp[0])
443 temp = [temp[-1]]
444 repeat = False
445 else:
446 if len(temp) >= 2 and temp[-1] == temp[-2]:
447 repeat = True
448 if len(temp) > 2:
449 append_range(temp[0:(len(temp) - 2)])
450 temp = [temp[-1], temp[-1]]
451 continue
452 if len(temp) == 128 or end:
453 append_range(temp)
454 temp = []
455 repeat = False
456 return output
diff --git a/lib/python/qmk/painter_qff.py b/lib/python/qmk/painter_qff.py
new file mode 100644
index 0000000000..746bb166e5
--- /dev/null
+++ b/lib/python/qmk/painter_qff.py
@@ -0,0 +1,401 @@
1# Copyright 2021 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3
4# Quantum Font File "QFF" Font File Format.
5# See https://docs.qmk.fm/#/quantum_painter_qff for more information.
6
7from pathlib import Path
8from typing import Dict, Any
9from colorsys import rgb_to_hsv
10from PIL import Image, ImageDraw, ImageFont, ImageChops
11from PIL._binary import o8, o16le as o16, o32le as o32
12from qmk.painter_qgf import QGFBlockHeader, QGFFramePaletteDescriptorV1
13from milc.attrdict import AttrDict
14import qmk.painter
15
16
17def o24(i):
18 return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
19
20
21########################################################################################################################
22
23
24class QFFGlyphInfo(AttrDict):
25 def __init__(self, *args, **kwargs):
26 super().__init__()
27
28 for n, value in enumerate(args):
29 self[f'arg:{n}'] = value
30
31 for key, value in kwargs.items():
32 self[key] = value
33
34 def write(self, fp, include_code_point):
35 if include_code_point is True:
36 fp.write(o24(ord(self.code_point)))
37
38 value = ((self.data_offset << 6) & 0xFFFFC0) | (self.w & 0x3F)
39 fp.write(o24(value))
40
41
42########################################################################################################################
43
44
45class QFFFontDescriptor:
46 type_id = 0x00
47 length = 20
48 magic = 0x464651
49
50 def __init__(self):
51 self.header = QGFBlockHeader()
52 self.header.type_id = QFFFontDescriptor.type_id
53 self.header.length = QFFFontDescriptor.length
54 self.version = 1
55 self.total_file_size = 0
56 self.line_height = 0
57 self.has_ascii_table = False
58 self.unicode_glyph_count = 0
59 self.format = 0xFF
60 self.flags = 0
61 self.compression = 0xFF
62 self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader
63
64 def write(self, fp):
65 self.header.write(fp)
66 fp.write(
67 b'' # start off with empty bytes...
68 + o24(QFFFontDescriptor.magic) # magic
69 + o8(self.version) # version
70 + o32(self.total_file_size) # file size
71 + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size
72 + o8(self.line_height) # line height
73 + o8(1 if self.has_ascii_table is True else 0) # whether or not we have an ascii table present
74 + o16(self.unicode_glyph_count & 0xFFFF) # number of unicode glyphs present
75 + o8(self.format) # format
76 + o8(self.flags) # flags
77 + o8(self.compression) # compression
78 + o8(self.transparency_index) # transparency index
79 )
80
81 @property
82 def is_transparent(self):
83 return (self.flags & 0x01) == 0x01
84
85 @is_transparent.setter
86 def is_transparent(self, val):
87 if val:
88 self.flags |= 0x01
89 else:
90 self.flags &= ~0x01
91
92
93########################################################################################################################
94
95
96class QFFAsciiGlyphTableV1:
97 type_id = 0x01
98 length = 95 * 3 # We have 95 glyphs: [0x20...0x7E]
99
100 def __init__(self):
101 self.header = QGFBlockHeader()
102 self.header.type_id = QFFAsciiGlyphTableV1.type_id
103 self.header.length = QFFAsciiGlyphTableV1.length
104
105 # Each glyph is key=code_point, value=QFFGlyphInfo
106 self.glyphs = {}
107
108 def add_glyph(self, glyph: QFFGlyphInfo):
109 self.glyphs[ord(glyph.code_point)] = glyph
110
111 def write(self, fp):
112 self.header.write(fp)
113
114 for n in range(0x20, 0x7F):
115 self.glyphs[n].write(fp, False)
116
117
118########################################################################################################################
119
120
121class QFFUnicodeGlyphTableV1:
122 type_id = 0x02
123
124 def __init__(self):
125 self.header = QGFBlockHeader()
126 self.header.type_id = QFFUnicodeGlyphTableV1.type_id
127 self.header.length = 0
128
129 # Each glyph is key=code_point, value=QFFGlyphInfo
130 self.glyphs = {}
131
132 def add_glyph(self, glyph: QFFGlyphInfo):
133 self.glyphs[ord(glyph.code_point)] = glyph
134
135 def write(self, fp):
136 self.header.length = len(self.glyphs.keys()) * 6
137 self.header.write(fp)
138
139 for n in sorted(self.glyphs.keys()):
140 self.glyphs[n].write(fp, True)
141
142
143########################################################################################################################
144
145
146class QFFFontDataDescriptorV1:
147 type_id = 0x04
148
149 def __init__(self):
150 self.header = QGFBlockHeader()
151 self.header.type_id = QFFFontDataDescriptorV1.type_id
152 self.data = []
153
154 def write(self, fp):
155 self.header.length = len(self.data)
156 self.header.write(fp)
157 fp.write(bytes(self.data))
158
159
160########################################################################################################################
161
162
163def _generate_font_glyphs_list(use_ascii, unicode_glyphs):
164 # The set of glyphs that we want to generate images for
165 glyphs = {}
166
167 # Add ascii charset if requested
168 if use_ascii is True:
169 for c in range(0x20, 0x7F): # does not include 0x7F!
170 glyphs[chr(c)] = True
171
172 # Append any extra unicode glyphs
173 unicode_glyphs = list(unicode_glyphs)
174 for c in unicode_glyphs:
175 glyphs[c] = True
176
177 return sorted(glyphs.keys())
178
179
180class QFFFont:
181 def __init__(self, logger):
182 self.logger = logger
183 self.image = None
184 self.glyph_data = {}
185 self.glyph_height = 0
186 return
187
188 def _extract_glyphs(self, format):
189 total_data_size = 0
190 total_rle_data_size = 0
191
192 converted_img = qmk.painter.convert_requested_format(self.image, format)
193 (self.palette, _) = qmk.painter.convert_image_bytes(converted_img, format)
194
195 # Work out how many bytes used for RLE vs. non-RLE
196 for _, glyph_entry in self.glyph_data.items():
197 glyph_img = converted_img.crop((glyph_entry.x, 1, glyph_entry.x + glyph_entry.w, 1 + self.glyph_height))
198 (_, this_glyph_image_bytes) = qmk.painter.convert_image_bytes(glyph_img, format)
199 this_glyph_rle_bytes = qmk.painter.compress_bytes_qmk_rle(this_glyph_image_bytes)
200 total_data_size += len(this_glyph_image_bytes)
201 total_rle_data_size += len(this_glyph_rle_bytes)
202 glyph_entry['image_uncompressed_bytes'] = this_glyph_image_bytes
203 glyph_entry['image_compressed_bytes'] = this_glyph_rle_bytes
204
205 return (total_data_size, total_rle_data_size)
206
207 def _parse_image(self, img, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
208 # Clear out any existing font metadata
209 self.image = None
210 # Each glyph is key=code_point, value={ x: ?, w: ? }
211 self.glyph_data = {}
212 self.glyph_height = 0
213
214 # Work out the list of glyphs required
215 glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs)
216
217 # Work out the geometry
218 (width, height) = img.size
219
220 # Work out the glyph offsets/widths
221 glyph_pixel_offsets = []
222 glyph_pixel_widths = []
223 pixels = img.load()
224
225 # Run through the markers and work out where each glyph starts/stops
226 glyph_split_color = pixels[0, 0] # top left pixel is the marker color we're going to use to split each glyph
227 glyph_pixel_offsets.append(0)
228 last_offset = 0
229 for x in range(1, width):
230 if pixels[x, 0] == glyph_split_color:
231 glyph_pixel_offsets.append(x)
232 glyph_pixel_widths.append(x - last_offset)
233 last_offset = x
234 glyph_pixel_widths.append(width - last_offset)
235
236 # Make sure the number of glyphs we're attempting to generate matches the input image
237 if len(glyph_pixel_offsets) != len(glyphs):
238 self.logger.error('The number of glyphs to generate doesn\'t match the number of detected glyphs in the input image.')
239 return
240
241 # Set up the required metadata for each glyph
242 for n in range(0, len(glyph_pixel_offsets)):
243 self.glyph_data[glyphs[n]] = QFFGlyphInfo(code_point=glyphs[n], x=glyph_pixel_offsets[n], w=glyph_pixel_widths[n])
244
245 # Parsing was successful, keep the image in this instance
246 self.image = img
247 self.glyph_height = height - 1 # subtract the line with the markers
248
249 def generate_image(self, ttf_file: Path, font_size: int, include_ascii_glyphs: bool = True, unicode_glyphs: str = '', include_before_left: bool = False, use_aa: bool = True):
250 # Load the font
251 font = ImageFont.truetype(str(ttf_file), int(font_size))
252 # Work out the max font size
253 max_font_size = font.font.ascent + abs(font.font.descent)
254 # Work out the list of glyphs required
255 glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs)
256
257 baseline_offset = 9999999
258 total_glyph_width = 0
259 max_glyph_height = -1
260
261 # Measure each glyph to determine the overall baseline offset required
262 for glyph in glyphs:
263 (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls')
264 glyph_width = (ls_r - ls_l) if include_before_left else (ls_r)
265 glyph_height = font.getbbox(glyph, anchor='la')[3]
266 if max_glyph_height < glyph_height:
267 max_glyph_height = glyph_height
268 total_glyph_width += glyph_width
269 if baseline_offset > ls_t:
270 baseline_offset = ls_t
271
272 # Create the output image
273 img = Image.new("RGB", (total_glyph_width + 1, max_font_size * 2 + 1), (0, 0, 0, 255))
274 cur_x_pos = 0
275
276 # Loop through each glyph...
277 for glyph in glyphs:
278 # Work out this glyph's bounding box
279 (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls')
280 glyph_width = (ls_r - ls_l) if include_before_left else (ls_r)
281 glyph_height = ls_b - ls_t
282 x_offset = -ls_l
283 y_offset = ls_t - baseline_offset
284
285 # Draw each glyph to its own image so we don't get anti-aliasing applied to the final image when straddling edges
286 glyph_img = Image.new("RGB", (glyph_width, max_font_size), (0, 0, 0, 255))
287 glyph_draw = ImageDraw.Draw(glyph_img)
288 if not use_aa:
289 glyph_draw.fontmode = "1"
290 glyph_draw.text((x_offset, y_offset), glyph, font=font, anchor='lt')
291
292 # Place the glyph-specific image in the correct location overall
293 img.paste(glyph_img, (cur_x_pos, 1))
294
295 # Set up the marker for start of each glyph
296 pixels = img.load()
297 pixels[cur_x_pos, 0] = (255, 0, 255)
298
299 # Increment for the next glyph's position
300 cur_x_pos += glyph_width
301
302 # Add the ending marker so that the difference/crop works
303 pixels = img.load()
304 pixels[cur_x_pos, 0] = (255, 0, 255)
305
306 # Determine the usable font area
307 dummy_img = Image.new("RGB", (total_glyph_width + 1, max_font_size + 1), (0, 0, 0, 255))
308 bbox = ImageChops.difference(img, dummy_img).getbbox()
309 bbox = (bbox[0], bbox[1], bbox[2] - 1, bbox[3]) # remove the unused end-marker
310
311 # Crop and re-parse the resulting image to ensure we're generating the correct format
312 self._parse_image(img.crop(bbox), include_ascii_glyphs, unicode_glyphs)
313
314 def save_to_image(self, img_file: Path):
315 # Drop out if there's no image loaded
316 if self.image is None:
317 self.logger.error('No image is loaded.')
318 return
319
320 # Save the image to the supplied file
321 self.image.save(str(img_file))
322
323 def read_from_image(self, img_file: Path, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
324 # Load and parse the supplied image file
325 self._parse_image(Image.open(str(img_file)), include_ascii_glyphs, unicode_glyphs)
326 return
327
328 def save_to_qff(self, format: Dict[str, Any], use_rle: bool, fp):
329 # Drop out if there's no image loaded
330 if self.image is None:
331 self.logger.error('No image is loaded.')
332 return
333
334 # Work out if we want to use RLE at all, skipping it if it's not any smaller (it's applied per-glyph)
335 (total_data_size, total_rle_data_size) = self._extract_glyphs(format)
336 if use_rle:
337 use_rle = (total_rle_data_size < total_data_size)
338
339 # For each glyph, work out which image data we want to use and append it to the image buffer, recording the byte-wise offset
340 img_buffer = bytes()
341 for _, glyph_entry in self.glyph_data.items():
342 glyph_entry['data_offset'] = len(img_buffer)
343 glyph_img_bytes = glyph_entry.image_compressed_bytes if use_rle else glyph_entry.image_uncompressed_bytes
344 img_buffer += bytes(glyph_img_bytes)
345
346 font_descriptor = QFFFontDescriptor()
347 ascii_table = QFFAsciiGlyphTableV1()
348 unicode_table = QFFUnicodeGlyphTableV1()
349 data_descriptor = QFFFontDataDescriptorV1()
350 data_descriptor.data = img_buffer
351
352 # Check if we have all the ASCII glyphs present
353 include_ascii_glyphs = all([chr(n) in self.glyph_data for n in range(0x20, 0x7F)])
354
355 # Helper for populating the blocks
356 for code_point, glyph_entry in self.glyph_data.items():
357 if ord(code_point) >= 0x20 and ord(code_point) <= 0x7E and include_ascii_glyphs:
358 ascii_table.add_glyph(glyph_entry)
359 else:
360 unicode_table.add_glyph(glyph_entry)
361
362 # Configure the font descriptor
363 font_descriptor.line_height = self.glyph_height
364 font_descriptor.has_ascii_table = include_ascii_glyphs
365 font_descriptor.unicode_glyph_count = len(unicode_table.glyphs.keys())
366 font_descriptor.is_transparent = False
367 font_descriptor.format = format['image_format_byte']
368 font_descriptor.compression = 0x01 if use_rle else 0x00
369
370 # Write a dummy font descriptor -- we'll have to come back and write it properly once we've rendered out everything else
371 font_descriptor_location = fp.tell()
372 font_descriptor.write(fp)
373
374 # Write out the ASCII table if required
375 if font_descriptor.has_ascii_table:
376 ascii_table.write(fp)
377
378 # Write out the unicode table if required
379 if font_descriptor.unicode_glyph_count > 0:
380 unicode_table.write(fp)
381
382 # Write out the palette if required
383 if format['has_palette']:
384 palette_descriptor = QGFFramePaletteDescriptorV1()
385
386 # Helper to convert from RGB888 to the QMK "dialect" of HSV888
387 def rgb888_to_qmk_hsv888(e):
388 hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0)
389 return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0))
390
391 # Convert all palette entries to HSV888 and write to the output
392 palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, self.palette))
393 palette_descriptor.write(fp)
394
395 # Write out the image data
396 data_descriptor.write(fp)
397
398 # Now fix up the overall font descriptor, then write it in the correct location
399 font_descriptor.total_file_size = fp.tell()
400 fp.seek(font_descriptor_location, 0)
401 font_descriptor.write(fp)
diff --git a/lib/python/qmk/painter_qgf.py b/lib/python/qmk/painter_qgf.py
new file mode 100644
index 0000000000..67ef0dd233
--- /dev/null
+++ b/lib/python/qmk/painter_qgf.py
@@ -0,0 +1,460 @@
1# Copyright 2021 Nick Brassel (@tzarc)
2# Copyright 2023 Pablo Martinez (@elpekenin) <elpekenin@elpekenin.dev>
3# SPDX-License-Identifier: GPL-2.0-or-later
4
5# Quantum Graphics File "QGF" Image File Format.
6# See https://docs.qmk.fm/#/quantum_painter_qgf for more information.
7
8import functools
9from colorsys import rgb_to_hsv
10from types import FunctionType
11from PIL import Image, ImageFile, ImageChops
12from PIL._binary import o8, o16le as o16, o32le as o32
13import qmk.painter
14
15
16def o24(i):
17 return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
18
19
20# Helper to convert from RGB888 to the QMK "dialect" of HSV888
21def rgb888_to_qmk_hsv888(e):
22 hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0)
23 return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0))
24
25
26########################################################################################################################
27
28
29class QGFBlockHeader:
30 block_size = 5
31
32 def write(self, fp):
33 fp.write(b'' # start off with empty bytes...
34 + o8(self.type_id) # block type id
35 + o8((~self.type_id) & 0xFF) # negated block type id
36 + o24(self.length) # blob length
37 )
38
39
40########################################################################################################################
41
42
43class QGFGraphicsDescriptor:
44 type_id = 0x00
45 length = 18
46 magic = 0x464751
47
48 def __init__(self):
49 self.header = QGFBlockHeader()
50 self.header.type_id = QGFGraphicsDescriptor.type_id
51 self.header.length = QGFGraphicsDescriptor.length
52 self.version = 1
53 self.total_file_size = 0
54 self.image_width = 0
55 self.image_height = 0
56 self.frame_count = 0
57
58 def write(self, fp):
59 self.header.write(fp)
60 fp.write(
61 b'' # start off with empty bytes...
62 + o24(QGFGraphicsDescriptor.magic) # magic
63 + o8(self.version) # version
64 + o32(self.total_file_size) # file size
65 + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size
66 + o16(self.image_width) # width
67 + o16(self.image_height) # height
68 + o16(self.frame_count) # frame count
69 )
70
71 @property
72 def image_size(self):
73 return self.image_width, self.image_height
74
75 @image_size.setter
76 def image_size(self, size):
77 self.image_width, self.image_height = size
78
79
80########################################################################################################################
81
82
83class QGFFrameOffsetDescriptorV1:
84 type_id = 0x01
85
86 def __init__(self, frame_count):
87 self.header = QGFBlockHeader()
88 self.header.type_id = QGFFrameOffsetDescriptorV1.type_id
89 self.frame_offsets = [0xFFFFFFFF] * frame_count
90 self.frame_count = frame_count
91
92 def write(self, fp):
93 self.header.length = len(self.frame_offsets) * 4
94 self.header.write(fp)
95 for offset in self.frame_offsets:
96 fp.write(b'' # start off with empty bytes...
97 + o32(offset) # offset
98 )
99
100
101########################################################################################################################
102
103
104class QGFFrameDescriptorV1:
105 type_id = 0x02
106 length = 6
107
108 def __init__(self):
109 self.header = QGFBlockHeader()
110 self.header.type_id = QGFFrameDescriptorV1.type_id
111 self.header.length = QGFFrameDescriptorV1.length
112 self.format = 0xFF
113 self.flags = 0
114 self.compression = 0xFF
115 self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader
116 self.delay = 1000 # Placeholder until it gets read from the animation
117
118 def write(self, fp):
119 self.header.write(fp)
120 fp.write(b'' # start off with empty bytes...
121 + o8(self.format) # format
122 + o8(self.flags) # flags
123 + o8(self.compression) # compression
124 + o8(self.transparency_index) # transparency index
125 + o16(self.delay) # delay
126 )
127
128 @property
129 def is_transparent(self):
130 return (self.flags & 0x01) == 0x01
131
132 @is_transparent.setter
133 def is_transparent(self, val):
134 if val:
135 self.flags |= 0x01
136 else:
137 self.flags &= ~0x01
138
139 @property
140 def is_delta(self):
141 return (self.flags & 0x02) == 0x02
142
143 @is_delta.setter
144 def is_delta(self, val):
145 if val:
146 self.flags |= 0x02
147 else:
148 self.flags &= ~0x02
149
150
151########################################################################################################################
152
153
154class QGFFramePaletteDescriptorV1:
155 type_id = 0x03
156
157 def __init__(self):
158 self.header = QGFBlockHeader()
159 self.header.type_id = QGFFramePaletteDescriptorV1.type_id
160 self.header.length = 0
161 self.palette_entries = [(0xFF, 0xFF, 0xFF)] * 4
162
163 def write(self, fp):
164 self.header.length = len(self.palette_entries) * 3
165 self.header.write(fp)
166 for entry in self.palette_entries:
167 fp.write(b'' # start off with empty bytes...
168 + o8(entry[0]) # h
169 + o8(entry[1]) # s
170 + o8(entry[2]) # v
171 )
172
173
174########################################################################################################################
175
176
177class QGFFrameDeltaDescriptorV1:
178 type_id = 0x04
179 length = 8
180
181 def __init__(self):
182 self.header = QGFBlockHeader()
183 self.header.type_id = QGFFrameDeltaDescriptorV1.type_id
184 self.header.length = QGFFrameDeltaDescriptorV1.length
185 self.left = 0
186 self.top = 0
187 self.right = 0
188 self.bottom = 0
189
190 def write(self, fp):
191 self.header.write(fp)
192 fp.write(b'' # start off with empty bytes...
193 + o16(self.left) # left
194 + o16(self.top) # top
195 + o16(self.right) # right
196 + o16(self.bottom) # bottom
197 )
198
199 @property
200 def bbox(self):
201 return self.left, self.top, self.right, self.bottom
202
203 @bbox.setter
204 def bbox(self, bbox):
205 self.left, self.top, self.right, self.bottom = bbox
206
207
208########################################################################################################################
209
210
211class QGFFrameDataDescriptorV1:
212 type_id = 0x05
213
214 def __init__(self):
215 self.header = QGFBlockHeader()
216 self.header.type_id = QGFFrameDataDescriptorV1.type_id
217 self.data = []
218
219 def write(self, fp):
220 self.header.length = len(self.data)
221 self.header.write(fp)
222 fp.write(bytes(self.data))
223
224
225########################################################################################################################
226
227
228class QGFImageFile(ImageFile.ImageFile):
229
230 format = "QGF"
231 format_description = "Quantum Graphics File Format"
232
233 def _open(self):
234 raise NotImplementedError("Reading QGF files is not supported")
235
236
237########################################################################################################################
238
239
240def _accept(prefix):
241 """Helper method used by PIL to work out if it can parse an input file.
242
243 Currently unimplemented.
244 """
245 return False
246
247
248def _for_all_frames(x: FunctionType, /, images):
249 frame_num = 0
250 last_frame = None
251 for frame in images:
252 # Get number of of frames in this image
253 nfr = getattr(frame, "n_frames", 1)
254 for idx in range(nfr):
255 frame.seek(idx)
256 frame.load()
257 copy = frame.copy().convert("RGB")
258 x(frame_num, copy, last_frame)
259 last_frame = copy
260 frame_num += 1
261
262
263def _compress_image(frame, last_frame, *, use_rle, use_deltas, format_, **_kwargs):
264 # Convert the original frame so we can do comparisons
265 converted = qmk.painter.convert_requested_format(frame, format_)
266 graphic_data = qmk.painter.convert_image_bytes(converted, format_)
267
268 # Convert the raw data to RLE-encoded if requested
269 raw_data = graphic_data[1]
270 if use_rle:
271 rle_data = qmk.painter.compress_bytes_qmk_rle(graphic_data[1])
272 use_raw_this_frame = not use_rle or len(raw_data) <= len(rle_data)
273 image_data = raw_data if use_raw_this_frame else rle_data
274
275 # Work out if a delta frame is smaller than injecting it directly
276 use_delta_this_frame = False
277 bbox = None
278 if use_deltas and last_frame is not None:
279 # If we want to use deltas, then find the difference
280 diff = ImageChops.difference(frame, last_frame)
281
282 # Get the bounding box of those differences
283 bbox = diff.getbbox()
284
285 # If we have a valid bounding box...
286 if bbox:
287 # ...create the delta frame by cropping the original.
288 delta_frame = frame.crop(bbox)
289
290 # Convert the delta frame to the requested format
291 delta_converted = qmk.painter.convert_requested_format(delta_frame, format_)
292 delta_graphic_data = qmk.painter.convert_image_bytes(delta_converted, format_)
293
294 # Work out how large the delta frame is going to be with compression etc.
295 delta_raw_data = delta_graphic_data[1]
296 if use_rle:
297 delta_rle_data = qmk.painter.compress_bytes_qmk_rle(delta_graphic_data[1])
298 delta_use_raw_this_frame = not use_rle or len(delta_raw_data) <= len(delta_rle_data)
299 delta_image_data = delta_raw_data if delta_use_raw_this_frame else delta_rle_data
300
301 # If the size of the delta frame (plus delta descriptor) is smaller than the original, use that instead
302 # This ensures that if a non-delta is overall smaller in size, we use that in preference due to flash
303 # sizing constraints.
304 if (len(delta_image_data) + QGFFrameDeltaDescriptorV1.length) < len(image_data):
305 # Copy across all the delta equivalents so that the rest of the processing acts on those
306 graphic_data = delta_graphic_data
307 raw_data = delta_raw_data
308 rle_data = delta_rle_data
309 use_raw_this_frame = delta_use_raw_this_frame
310 image_data = delta_image_data
311 use_delta_this_frame = True
312
313 # Default to whole image
314 bbox = bbox or [0, 0, *frame.size]
315 # Fix sze (as per #20296), we need to cast first as tuples are inmutable
316 bbox = list(bbox)
317 bbox[2] -= 1
318 bbox[3] -= 1
319
320 return {
321 "bbox": bbox,
322 "graphic_data": graphic_data,
323 "image_data": image_data,
324 "use_delta_this_frame": use_delta_this_frame,
325 "use_raw_this_frame": use_raw_this_frame,
326 }
327
328
329# Helper function to save each frame to the output file
330def _write_frame(idx, frame, last_frame, *, fp, frame_offsets, metadata, **kwargs):
331 # Not an argument of the function as it would then not be part of kwargs
332 # This would cause an issue with `_compress_image(**kwargs)` missing an argument
333 format_ = kwargs["format_"]
334
335 # (potentially) Apply RLE and/or delta, and work out output image's information
336 outputs = _compress_image(frame, last_frame, **kwargs)
337 bbox = outputs["bbox"]
338 graphic_data = outputs["graphic_data"]
339 image_data = outputs["image_data"]
340 use_delta_this_frame = outputs["use_delta_this_frame"]
341 use_raw_this_frame = outputs["use_raw_this_frame"]
342
343 # Write out the frame descriptor
344 frame_offsets.frame_offsets[idx] = fp.tell()
345 vprint(f'{f"Frame {idx:3d} base":26s} {fp.tell():5d}d / {fp.tell():04X}h')
346 frame_descriptor = QGFFrameDescriptorV1()
347 frame_descriptor.is_delta = use_delta_this_frame
348 frame_descriptor.is_transparent = False
349 frame_descriptor.format = format_['image_format_byte']
350 frame_descriptor.compression = 0x00 if use_raw_this_frame else 0x01 # See qp.h, painter_compression_t
351 frame_descriptor.delay = frame.info.get('duration', 1000) # If we're not an animation, just pretend we're delaying for 1000ms
352 frame_descriptor.write(fp)
353
354 # Write out the palette if required
355 if format_['has_palette']:
356 palette = graphic_data[0]
357 palette_descriptor = QGFFramePaletteDescriptorV1()
358
359 # Convert all palette entries to HSV888 and write to the output
360 palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, palette))
361 vprint(f'{f"Frame {idx:3d} palette":26s} {fp.tell():5d}d / {fp.tell():04X}h')
362 palette_descriptor.write(fp)
363
364 # Write out the delta info if required
365 if use_delta_this_frame:
366 # Set up the rendering location of where the delta frame should be situated
367 delta_descriptor = QGFFrameDeltaDescriptorV1()
368 delta_descriptor.bbox = bbox
369
370 # Write the delta frame to the output
371 vprint(f'{f"Frame {idx:3d} delta":26s} {fp.tell():5d}d / {fp.tell():04X}h')
372 delta_descriptor.write(fp)
373
374 # Store metadata, showed later in a comment in the generated file
375 frame_metadata = {
376 "compression": frame_descriptor.compression,
377 "delta": frame_descriptor.is_delta,
378 "delay": frame_descriptor.delay,
379 }
380 if frame_metadata["delta"]:
381 frame_metadata.update({"delta_rect": [
382 delta_descriptor.left,
383 delta_descriptor.top,
384 delta_descriptor.right,
385 delta_descriptor.bottom,
386 ]})
387 metadata.append(frame_metadata)
388
389 # Write out the data for this frame to the output
390 data_descriptor = QGFFrameDataDescriptorV1()
391 data_descriptor.data = image_data
392 vprint(f'{f"Frame {idx:3d} data":26s} {fp.tell():5d}d / {fp.tell():04X}h')
393 data_descriptor.write(fp)
394
395
396def _save(im, fp, _filename):
397 """Helper method used by PIL to write to an output file.
398 """
399 # Work out from the parameters if we need to do anything special
400 encoderinfo = im.encoderinfo.copy()
401
402 # Store image file in metadata structure
403 metadata = encoderinfo.get("metadata", [])
404 metadata.append({"width": im.width, "height": im.height})
405
406 # Helper for prints, noop taking any args if not verbose
407 global vprint
408 verbose = encoderinfo.get("verbose", False)
409 vprint = print if verbose else lambda *_args, **_kwargs: None
410
411 # Helper to iterate through all frames in the input image
412 append_images = list(encoderinfo.get("append_images", []))
413 for_all_frames = functools.partial(_for_all_frames, images=[im, *append_images])
414
415 # Collect all the frame sizes
416 frame_sizes = []
417 for_all_frames(lambda _idx, frame, _last_frame: frame_sizes.append(frame.size))
418
419 # Make sure all frames are the same size
420 if len(set(frame_sizes)) != 1:
421 raise ValueError("Mismatching sizes on frames")
422
423 # Write out the initial graphics descriptor (and write a dummy value), so that we can come back and fill in the
424 # correct values once we've written all the frames to the output
425 graphics_descriptor_location = fp.tell()
426 graphics_descriptor = QGFGraphicsDescriptor()
427 graphics_descriptor.frame_count = len(frame_sizes)
428 graphics_descriptor.image_size = frame_sizes[0]
429 vprint(f'{"Graphics descriptor block":26s} {fp.tell():5d}d / {fp.tell():04X}h')
430 graphics_descriptor.write(fp)
431
432 # Work out the frame offset descriptor location (and write a dummy value), so that we can come back and fill in the
433 # correct offsets once we've written all the frames to the output
434 frame_offset_location = fp.tell()
435 frame_offsets = QGFFrameOffsetDescriptorV1(graphics_descriptor.frame_count)
436 vprint(f'{"Frame offsets block":26s} {fp.tell():5d}d / {fp.tell():04X}h')
437 frame_offsets.write(fp)
438
439 # Iterate over each if the input frames, writing it to the output in the process
440 write_frame = functools.partial(_write_frame, format_=encoderinfo["qmk_format"], fp=fp, use_deltas=encoderinfo.get("use_deltas", True), use_rle=encoderinfo.get("use_rle", True), frame_offsets=frame_offsets, metadata=metadata)
441 for_all_frames(write_frame)
442
443 # Go back and update the graphics descriptor now that we can determine the final file size
444 graphics_descriptor.total_file_size = fp.tell()
445 fp.seek(graphics_descriptor_location, 0)
446 graphics_descriptor.write(fp)
447
448 # Go back and update the frame offsets now that they're written to the file
449 fp.seek(frame_offset_location, 0)
450 frame_offsets.write(fp)
451
452
453########################################################################################################################
454
455# Register with PIL so that it knows about the QGF format
456Image.register_open(QGFImageFile.format, QGFImageFile, _accept)
457Image.register_save(QGFImageFile.format, _save)
458Image.register_save_all(QGFImageFile.format, _save)
459Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}")
460Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}")
diff --git a/lib/python/qmk/path.py b/lib/python/qmk/path.py
new file mode 100644
index 0000000000..1739689adf
--- /dev/null
+++ b/lib/python/qmk/path.py
@@ -0,0 +1,181 @@
1"""Functions that help us work with files and folders.
2"""
3import logging
4import os
5import argparse
6from pathlib import Path, PureWindowsPath, PurePosixPath
7
8from qmk.constants import MAX_KEYBOARD_SUBFOLDERS, QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
9from qmk.errors import NoSuchKeyboardError
10
11
12def is_keyboard(keyboard_name):
13 """Returns True if `keyboard_name` is a keyboard we can compile.
14 """
15 if not keyboard_name:
16 return False
17
18 # keyboard_name values of 'c:/something' or '/something' trigger append issues
19 # due to "If the argument is an absolute path, the previous path is ignored"
20 # however it should always be a folder located under qmk_firmware/keyboards
21 if Path(keyboard_name).is_absolute():
22 return False
23
24 keyboard_json = QMK_FIRMWARE / 'keyboards' / keyboard_name / 'keyboard.json'
25
26 return keyboard_json.exists()
27
28
29def under_qmk_firmware(path=Path(os.environ['ORIG_CWD'])):
30 """Returns a Path object representing the relative path under qmk_firmware, or None.
31 """
32 try:
33 return path.relative_to(QMK_FIRMWARE)
34 except ValueError:
35 return None
36
37
38def under_qmk_userspace(path=Path(os.environ['ORIG_CWD'])):
39 """Returns a Path object representing the relative path under $QMK_USERSPACE, or None.
40 """
41 try:
42 if HAS_QMK_USERSPACE:
43 return path.relative_to(QMK_USERSPACE)
44 except ValueError:
45 pass
46 return None
47
48
49def is_under_qmk_firmware(path=Path(os.environ['ORIG_CWD'])):
50 """Returns a boolean if the input path is a child under qmk_firmware.
51 """
52 if path is None:
53 return False
54 try:
55 return Path(os.path.commonpath([Path(path), QMK_FIRMWARE])) == QMK_FIRMWARE
56 except ValueError:
57 return False
58
59
60def is_under_qmk_userspace(path=Path(os.environ['ORIG_CWD'])):
61 """Returns a boolean if the input path is a child under $QMK_USERSPACE.
62 """
63 if path is None:
64 return False
65 try:
66 if HAS_QMK_USERSPACE:
67 return Path(os.path.commonpath([Path(path), QMK_USERSPACE])) == QMK_USERSPACE
68 except ValueError:
69 return False
70
71
72def keyboard(keyboard_name):
73 """Returns the path to a keyboard's directory relative to the qmk root.
74 """
75 return Path('keyboards') / keyboard_name
76
77
78def keymaps(keyboard_name):
79 """Returns all of the `keymaps/` directories for a given keyboard.
80
81 Args:
82
83 keyboard_name
84 The name of the keyboard. Example: clueboard/66/rev3
85 """
86 keyboard_folder = keyboard(keyboard_name)
87 found_dirs = []
88
89 if HAS_QMK_USERSPACE:
90 this_keyboard_folder = Path(QMK_USERSPACE) / keyboard_folder
91 for _ in range(MAX_KEYBOARD_SUBFOLDERS):
92 if (this_keyboard_folder / 'keymaps').exists():
93 found_dirs.append((this_keyboard_folder / 'keymaps').resolve())
94
95 this_keyboard_folder = this_keyboard_folder.parent
96 if this_keyboard_folder.resolve() == QMK_USERSPACE.resolve():
97 break
98
99 # We don't have any relevant keymap directories in userspace, so we'll use the fully-qualified path instead.
100 if len(found_dirs) == 0:
101 found_dirs.append((QMK_USERSPACE / keyboard_folder / 'keymaps').resolve())
102
103 this_keyboard_folder = QMK_FIRMWARE / keyboard_folder
104 for _ in range(MAX_KEYBOARD_SUBFOLDERS):
105 if (this_keyboard_folder / 'keymaps').exists():
106 found_dirs.append((this_keyboard_folder / 'keymaps').resolve())
107
108 this_keyboard_folder = this_keyboard_folder.parent
109 if this_keyboard_folder.resolve() == QMK_FIRMWARE.resolve():
110 break
111
112 if len(found_dirs) > 0:
113 return found_dirs
114
115 logging.error('Could not find the keymaps directory!')
116 raise NoSuchKeyboardError('Could not find keymaps directory for: %s' % keyboard_name)
117
118
119def keymap(keyboard_name, keymap_name):
120 """Locate the directory of a given keymap.
121
122 Args:
123
124 keyboard_name
125 The name of the keyboard. Example: clueboard/66/rev3
126 keymap_name
127 The name of the keymap. Example: default
128 """
129 for keymap_dir in keymaps(keyboard_name):
130 if (keymap_dir / keymap_name).exists():
131 return (keymap_dir / keymap_name).resolve()
132
133
134def normpath(path):
135 """Returns a `pathlib.Path()` object for a given path.
136
137 This will use the path to a file as seen from the directory the script was called from. You should use this to normalize filenames supplied from the command line.
138 """
139 path = Path(path)
140
141 if path.is_absolute():
142 return path
143
144 return Path(os.environ['ORIG_CWD']) / path
145
146
147def unix_style_path(path):
148 """Converts a Windows-style path with drive letter to a Unix path.
149
150 Path().as_posix() normally returns the path with drive letter and forward slashes, so is inappropriate for `Makefile` paths.
151
152 Passes through unadulterated if the path is not a Windows-style path.
153
154 Args:
155
156 path
157 The path to convert.
158
159 Returns:
160 The input path converted to Unix format.
161 """
162 if isinstance(path, PureWindowsPath):
163 p = list(path.parts)
164 p[0] = f'/{p[0][0].lower()}' # convert from `X:/` to `/x`
165 path = PurePosixPath(*p)
166 return path
167
168
169class FileType(argparse.FileType):
170 def __init__(self, *args, **kwargs):
171 # Use UTF8 by default for stdin
172 if 'encoding' not in kwargs:
173 kwargs['encoding'] = 'UTF-8'
174 return super().__init__(*args, **kwargs)
175
176 def __call__(self, string):
177 """normalize and check exists
178 otherwise magic strings like '-' for stdin resolve to bad paths
179 """
180 norm = normpath(string)
181 return norm if norm.exists() else super().__call__(string)
diff --git a/lib/python/qmk/search.py b/lib/python/qmk/search.py
new file mode 100644
index 0000000000..c7bce344ad
--- /dev/null
+++ b/lib/python/qmk/search.py
@@ -0,0 +1,329 @@
1"""Functions for searching through QMK keyboards and keymaps.
2"""
3from dataclasses import dataclass
4import contextlib
5import functools
6import fnmatch
7import json
8import logging
9import re
10from typing import Callable, Dict, List, Optional, Tuple, Union
11from dotty_dict import dotty, Dotty
12from milc import cli
13
14from qmk.util import parallel_map
15from qmk.info import keymap_json
16from qmk.keyboard import list_keyboards, keyboard_folder
17from qmk.keymap import list_keymaps, locate_keymap
18from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget
19
20
21@dataclass
22class KeyboardKeymapDesc:
23 keyboard: str
24 keymap: str
25 data: dict = None
26 extra_args: dict = None
27
28 def __hash__(self) -> int:
29 return self.keyboard.__hash__() ^ self.keymap.__hash__() ^ json.dumps(self.extra_args, sort_keys=True).__hash__()
30
31 def __lt__(self, other) -> bool:
32 return (self.keyboard, self.keymap, json.dumps(self.extra_args, sort_keys=True)) < (other.keyboard, other.keymap, json.dumps(other.extra_args, sort_keys=True))
33
34 def load_data(self):
35 data = keymap_json(self.keyboard, self.keymap)
36 self.data = data.to_dict() if isinstance(data, Dotty) else data
37
38 @property
39 def dotty(self) -> Dotty:
40 return dotty(self.data) if self.data is not None else None
41
42 def to_build_target(self) -> KeyboardKeymapBuildTarget:
43 target = KeyboardKeymapBuildTarget(keyboard=self.keyboard, keymap=self.keymap, json=self.data)
44 target.extra_args = self.extra_args
45 return target
46
47
48# by using a class for filters, we dont need to worry about capturing values
49# see details <https://github.com/qmk/qmk_firmware/pull/21090>
50class FilterFunction:
51 """Base class for filters.
52 It provides:
53 - __init__: capture key and value
54
55 Each subclass should provide:
56 - func_name: how it will be specified on CLI
57 >>> qmk find -f <func_name>...
58 - apply: function that actually applies the filter
59 ie: return whether the input kb/km satisfies the condition
60 """
61
62 key: str
63 value: Optional[str]
64
65 func_name: str
66 apply: Callable[[KeyboardKeymapDesc], bool]
67
68 def __init__(self, key, value):
69 self.key = key
70 self.value = value
71
72
73class Exists(FilterFunction):
74 func_name = "exists"
75
76 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
77 return self.key in target_info.dotty
78
79
80class Absent(FilterFunction):
81 func_name = "absent"
82
83 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
84 return self.key not in target_info.dotty
85
86
87class Length(FilterFunction):
88 func_name = "length"
89
90 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
91 info_dotty = target_info.dotty
92 return (self.key in info_dotty and len(info_dotty[self.key]) == int(self.value))
93
94
95class Contains(FilterFunction):
96 func_name = "contains"
97
98 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
99 info_dotty = target_info.dotty
100 return (self.key in info_dotty and self.value in info_dotty[self.key])
101
102
103def _get_filter_class(func_name: str, key: str, value: str) -> Optional[FilterFunction]:
104 """Initialize a filter subclass based on regex findings and return it.
105 None if no there's no filter with the name queried.
106 """
107
108 for subclass in FilterFunction.__subclasses__():
109 if func_name == subclass.func_name:
110 return subclass(key, value)
111
112 return None
113
114
115def filter_help() -> str:
116 names = [f"'{f.func_name}'" for f in FilterFunction.__subclasses__()]
117 return ", ".join(names[:-1]) + f" and {names[-1]}"
118
119
120def _set_log_level(level):
121 cli.acquire_lock()
122 try:
123 old = cli.log_level
124 cli.log_level = level
125 except AttributeError:
126 old = cli.log.level
127 cli.log.setLevel(level)
128 logging.root.setLevel(level)
129 cli.release_lock()
130 return old
131
132
133@contextlib.contextmanager
134def ignore_logging():
135 old = _set_log_level(logging.CRITICAL)
136 yield
137 _set_log_level(old)
138
139
140def _all_keymaps(keyboard) -> List[KeyboardKeymapDesc]:
141 """Returns a list of KeyboardKeymapDesc for all keymaps for the given keyboard.
142 """
143 with ignore_logging():
144 keyboard = keyboard_folder(keyboard)
145 return [KeyboardKeymapDesc(keyboard, keymap) for keymap in list_keymaps(keyboard)]
146
147
148def _keymap_exists(keyboard, keymap):
149 """Returns the keyboard name if the keyboard+keymap combination exists, otherwise None.
150 """
151 with ignore_logging():
152 return keyboard if locate_keymap(keyboard, keymap) is not None else None
153
154
155def _load_keymap_info(target: KeyboardKeymapDesc) -> KeyboardKeymapDesc:
156 """Ensures a KeyboardKeymapDesc has its data loaded.
157 """
158 with ignore_logging():
159 target.load_data() # Ensure we load the data first
160 return target
161
162
163def expand_make_targets(targets: List[Union[str, Tuple[str, Dict[str, str]]]]) -> List[KeyboardKeymapDesc]:
164 """Expand a list of make targets into a list of KeyboardKeymapDesc.
165
166 Caters for 'all' in either keyboard or keymap, or both.
167 """
168 split_targets = []
169 for target in targets:
170 extra_args = None
171 if isinstance(target, tuple):
172 split_target = target[0].split(':')
173 extra_args = target[1]
174 else:
175 split_target = target.split(':')
176 if len(split_target) != 2:
177 cli.log.error(f"Invalid build target: {target}")
178 return []
179 split_targets.append(KeyboardKeymapDesc(split_target[0], split_target[1], extra_args=extra_args))
180 return expand_keymap_targets(split_targets)
181
182
183def _expand_keymap_target(target: KeyboardKeymapDesc, all_keyboards: List[str] = None) -> List[KeyboardKeymapDesc]:
184 """Expand a keyboard input and keymap input into a list of KeyboardKeymapDesc.
185
186 Caters for 'all' in either keyboard or keymap, or both.
187 """
188 if all_keyboards is None:
189 all_keyboards = list_keyboards()
190
191 if target.keyboard == 'all':
192 if target.keymap == 'all':
193 cli.log.info('Retrieving list of all keyboards and keymaps...')
194 targets = []
195 for kb in parallel_map(_all_keymaps, all_keyboards):
196 targets.extend(kb)
197 for t in targets:
198 t.extra_args = target.extra_args
199 return targets
200 else:
201 cli.log.info(f'Retrieving list of keyboards with keymap "{target.keymap}"...')
202 keyboard_filter = functools.partial(_keymap_exists, keymap=target.keymap)
203 return [KeyboardKeymapDesc(kb, target.keymap, extra_args=target.extra_args) for kb in filter(lambda e: e is not None, parallel_map(keyboard_filter, all_keyboards))]
204 else:
205 if target.keymap == 'all':
206 cli.log.info(f'Retrieving list of keymaps for keyboard "{target.keyboard}"...')
207 targets = _all_keymaps(target.keyboard)
208 for t in targets:
209 t.extra_args = target.extra_args
210 return targets
211 else:
212 return [target]
213
214
215def expand_keymap_targets(targets: List[KeyboardKeymapDesc]) -> List[KeyboardKeymapDesc]:
216 """Expand a list of KeyboardKeymapDesc inclusive of 'all', into a list of explicit KeyboardKeymapDesc.
217 """
218 overall_targets = []
219 all_keyboards = list_keyboards()
220 for target in targets:
221 overall_targets.extend(_expand_keymap_target(target, all_keyboards))
222 return list(sorted(set(overall_targets)))
223
224
225def _construct_build_target(e: KeyboardKeymapDesc):
226 return e.to_build_target()
227
228
229def _filter_keymap_targets(target_list: List[KeyboardKeymapDesc], filters: List[str] = []) -> List[KeyboardKeymapDesc]:
230 """Filter a list of KeyboardKeymapDesc based on the supplied filters.
231
232 Optionally includes the values of the queried info.json keys.
233 """
234 if len(filters) == 0:
235 cli.log.info('Preparing target list...')
236 targets = target_list
237 else:
238 cli.log.info('Parsing data for all matching keyboard/keymap combinations...')
239 valid_targets = parallel_map(_load_keymap_info, target_list)
240
241 function_re = re.compile(r'^(?P<function>[a-zA-Z]+)\((?P<key>[a-zA-Z0-9_\.]+)(,\s*(?P<value>[^#]+))?\)$')
242 comparison_re = re.compile(r'^(?P<key>[a-zA-Z0-9_\.]+)\s*(?P<op>[\<\>\!=]=|\<|\>)\s*(?P<value>[^#]+)$')
243
244 for filter_expr in filters:
245 function_match = function_re.match(filter_expr)
246 comparison_match = comparison_re.match(filter_expr)
247
248 if function_match is not None:
249 func_name = function_match.group('function').lower()
250 key = function_match.group('key')
251 value = function_match.group('value')
252
253 filter_class = _get_filter_class(func_name, key, value)
254 if filter_class is None:
255 cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}')
256 continue
257 valid_targets = filter(filter_class.apply, valid_targets)
258
259 value_str = f", {{fg_cyan}}{value}{{fg_reset}}" if value is not None else ""
260 cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}{value_str})...')
261
262 elif comparison_match is not None:
263 key = comparison_match.group('key')
264 op = comparison_match.group('op')
265 value = comparison_match.group('value')
266 cli.log.info(f'Filtering on condition: {{fg_cyan}}{key}{{fg_reset}} {op} {{fg_cyan}}{value}{{fg_reset}}...')
267
268 def _make_filter(k, o, v):
269 expr = fnmatch.translate(v)
270 rule = re.compile(f'^{expr}$', re.IGNORECASE)
271
272 def f(e: KeyboardKeymapDesc):
273 lhs = e.dotty.get(k)
274 rhs = v
275
276 if o in ['<', '>', '<=', '>=']:
277 lhs = int(False if lhs is None else lhs)
278 rhs = int(rhs)
279
280 if o == '<':
281 return lhs < rhs
282 elif o == '>':
283 return lhs > rhs
284 elif o == '<=':
285 return lhs <= rhs
286 elif o == '>=':
287 return lhs >= rhs
288 else:
289 lhs = str(False if lhs is None else lhs)
290
291 if o == '!=':
292 return rule.search(lhs) is None
293 elif o == '==':
294 return rule.search(lhs) is not None
295
296 return f
297
298 valid_targets = filter(_make_filter(key, op, value), valid_targets)
299 else:
300 cli.log.warning(f'Unrecognized filter expression: {filter_expr}')
301 continue
302
303 cli.log.info('Preparing target list...')
304 targets = list(sorted(set(valid_targets)))
305
306 return targets
307
308
309def search_keymap_targets(targets: List[Union[Tuple[str, str], Tuple[str, str, Dict[str, str]]]] = [('all', 'default')], filters: List[str] = []) -> List[BuildTarget]:
310 """Search for build targets matching the supplied criteria.
311 """
312 def _make_desc(e):
313 if len(e) == 3:
314 return KeyboardKeymapDesc(keyboard=e[0], keymap=e[1], extra_args=e[2])
315 else:
316 return KeyboardKeymapDesc(keyboard=e[0], keymap=e[1])
317
318 targets = map(_make_desc, targets)
319 targets = _filter_keymap_targets(expand_keymap_targets(targets), filters)
320 targets = list(set(parallel_map(_construct_build_target, list(targets))))
321 return sorted(targets)
322
323
324def search_make_targets(targets: List[Union[str, Tuple[str, Dict[str, str]]]], filters: List[str] = []) -> List[BuildTarget]:
325 """Search for build targets matching the supplied criteria.
326 """
327 targets = _filter_keymap_targets(expand_make_targets(targets), filters)
328 targets = list(set(parallel_map(_construct_build_target, list(targets))))
329 return sorted(targets)
diff --git a/lib/python/qmk/submodules.py b/lib/python/qmk/submodules.py
new file mode 100644
index 0000000000..d0050b371d
--- /dev/null
+++ b/lib/python/qmk/submodules.py
@@ -0,0 +1,90 @@
1"""Functions for working with QMK's submodules.
2"""
3from milc import cli
4
5
6def status():
7 """Returns a dictionary of submodules.
8
9 Each entry is a dict of the form:
10
11 {
12 'name': 'submodule_name',
13 'status': None/False/True,
14 'githash': '<sha-1 hash for the submodule>'
15 'shorthash': '<short hash for the submodule>'
16 'describe': '<output of `git describe --tags`>'
17 'last_log_message': 'log message'
18 'last_log_timestamp': 'timestamp'
19 }
20
21 status is None when the submodule doesn't exist, False when it's out of date, and True when it's current
22 """
23 submodules = {}
24 gitmodule_config = cli.run(['git', 'config', '-f', '.gitmodules', '-l'], timeout=30)
25 for line in gitmodule_config.stdout.splitlines():
26 key, value = line.split('=', maxsplit=2)
27 if key.endswith('.path'):
28 submodules[value] = {'name': value, 'status': None}
29
30 git_cmd = cli.run(['git', 'submodule', 'status'], timeout=30)
31 for line in git_cmd.stdout.splitlines():
32 status = line[0]
33 githash, submodule = line[1:].split()[:2]
34 submodules[submodule]['githash'] = githash
35
36 if status == '-':
37 submodules[submodule]['status'] = None
38 elif status == '+':
39 submodules[submodule]['status'] = False
40 elif status == ' ':
41 submodules[submodule]['status'] = True
42 else:
43 raise ValueError('Unknown `git submodule status` sha-1 prefix character: "%s"' % status)
44
45 submodule_logs = cli.run(['git', 'submodule', '-q', 'foreach', 'git --no-pager log --no-show-signature --pretty=format:"$sm_path%x01%h%x01%ad%x01%s%x0A" --date=iso -n1'])
46 for log_line in submodule_logs.stdout.splitlines():
47 r = log_line.split('\x01')
48 submodule = r[0]
49 submodules[submodule]['shorthash'] = r[1] if len(r) > 1 else ''
50 submodules[submodule]['last_log_timestamp'] = r[2] if len(r) > 2 else ''
51 submodules[submodule]['last_log_message'] = r[3] if len(r) > 3 else ''
52
53 submodule_tags = cli.run(['git', 'submodule', '-q', 'foreach', '\'echo $sm_path `git describe --tags`\''])
54 for log_line in submodule_tags.stdout.splitlines():
55 r = log_line.split()
56 submodule = r[0]
57 submodules[submodule]['describe'] = r[1] if len(r) > 1 else ''
58
59 return submodules
60
61
62def update(submodules=None):
63 """Update the submodules.
64
65 submodules
66 A string containing a single submodule or a list of submodules.
67 """
68 git_sync_cmd = ['git', 'submodule', 'sync']
69 git_update_cmd = ['git', 'submodule', 'update', '--init']
70
71 if submodules is None:
72 # Update everything
73 git_sync_cmd.append('--recursive')
74 git_update_cmd.append('--recursive')
75 cli.run(git_sync_cmd, check=True)
76 cli.run(git_update_cmd, check=True)
77
78 else:
79 if isinstance(submodules, str):
80 # Update only a single submodule
81 git_sync_cmd.append(submodules)
82 git_update_cmd.append(submodules)
83 cli.run(git_sync_cmd, check=True)
84 cli.run(git_update_cmd, check=True)
85
86 else:
87 # Update submodules in a list
88 for submodule in submodules:
89 cli.run([*git_sync_cmd, submodule], check=True)
90 cli.run([*git_update_cmd, submodule], check=True)
diff --git a/lib/python/qmk/tests/.gitignore b/lib/python/qmk/tests/.gitignore
new file mode 100644
index 0000000000..eeb6581b87
--- /dev/null
+++ b/lib/python/qmk/tests/.gitignore
@@ -0,0 +1,2 @@
1# Ignore generated info.json from pytest
2info.json
diff --git a/lib/python/qmk/tests/__init__.py b/lib/python/qmk/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/lib/python/qmk/tests/__init__.py
diff --git a/lib/python/qmk/tests/attrdict.py b/lib/python/qmk/tests/attrdict.py
new file mode 100644
index 0000000000..a2584b9233
--- /dev/null
+++ b/lib/python/qmk/tests/attrdict.py
@@ -0,0 +1,8 @@
1class AttrDict(dict):
2 """A dictionary that can be accessed by attributes.
3
4 This should only be used to mock objects for unit testing. Please do not use this outside of qmk.tests.
5 """
6 def __init__(self, *args, **kwargs):
7 super(AttrDict, self).__init__(*args, **kwargs)
8 self.__dict__ = self
diff --git a/lib/python/qmk/tests/kle.txt b/lib/python/qmk/tests/kle.txt
new file mode 100644
index 0000000000..862a899ab9
--- /dev/null
+++ b/lib/python/qmk/tests/kle.txt
@@ -0,0 +1,5 @@
1["¬\n`","!\n1","\"\n2","£\n3","$\n4","%\n5","^\n6","&\n7","*\n8","(\n9",")\n0","_\n-","+\n=",{w:2},"Backspace"],
2[{w:1.5},"Tab","Q","W","E","R","T","Y","U","I","O","P","{\n[","}\n]",{x:0.25,w:1.25,h:2,w2:1.5,h2:1,x2:-0.25},"Enter"],
3[{w:1.75},"Caps Lock","A","S","D","F","G","H","J","K","L",":\n;","@\n'","~\n#"],
4[{w:1.25},"Shift","|\n\\","Z","X","C","V","B","N","M","<\n,",">\n.","?\n/",{w:2.75},"Shift"],
5[{w:1.25},"Ctrl",{w:1.25},"Win",{w:1.25},"Alt",{a:7,w:6.25},"",{a:4,w:1.25},"AltGr",{w:1.25},"Win",{w:1.25},"Menu",{w:1.25},"Ctrl"]
diff --git a/lib/python/qmk/tests/minimal_info.json b/lib/python/qmk/tests/minimal_info.json
new file mode 100644
index 0000000000..7f5ec1f983
--- /dev/null
+++ b/lib/python/qmk/tests/minimal_info.json
@@ -0,0 +1,11 @@
1{
2 "keyboard_name": "tester",
3 "maintainer": "qmk",
4 "layouts": {
5 "LAYOUT": {
6 "layout": [
7 {"label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0}
8 ]
9 }
10 }
11}
diff --git a/lib/python/qmk/tests/minimal_keymap.json b/lib/python/qmk/tests/minimal_keymap.json
new file mode 100644
index 0000000000..258f9e8a9a
--- /dev/null
+++ b/lib/python/qmk/tests/minimal_keymap.json
@@ -0,0 +1,7 @@
1{
2 "keyboard": "handwired/pytest/basic",
3 "keymap": "test",
4 "layers": [["KC_A"]],
5 "layout": "LAYOUT_ortho_1x1",
6 "version": 1
7}
diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py
new file mode 100644
index 0000000000..2716459989
--- /dev/null
+++ b/lib/python/qmk/tests/test_cli_commands.py
@@ -0,0 +1,408 @@
1import platform
2from subprocess import DEVNULL
3
4from milc import cli
5
6is_windows = 'windows' in platform.platform().lower()
7
8
9def check_subcommand(command, *args):
10 cmd = ['qmk', command, *args]
11 result = cli.run(cmd, stdin=DEVNULL, combined_output=True)
12 return result
13
14
15def check_subcommand_stdin(file_to_read, command, *args):
16 """Pipe content of a file to a command and return output.
17 """
18 with open(file_to_read, encoding='utf-8') as my_file:
19 cmd = ['qmk', command, *args]
20 result = cli.run(cmd, stdin=my_file, combined_output=True)
21 return result
22
23
24def check_returncode(result, expected=[0]):
25 """Print stdout if `result.returncode` does not match `expected`.
26 """
27 if result.returncode not in expected:
28 print('`%s` stdout:' % ' '.join(result.args))
29 print(result.stdout)
30 print('returncode:', result.returncode)
31 assert result.returncode in expected
32
33
34def test_format_c():
35 result = check_subcommand('format-c', '-n', 'quantum/matrix.c')
36 check_returncode(result)
37
38
39def test_format_c_all():
40 result = check_subcommand('format-c', '-n', '-a')
41 check_returncode(result, [0, 1])
42
43
44def test_compile():
45 result = check_subcommand('compile', '-kb', 'handwired/pytest/basic', '-km', 'default', '-n')
46 check_returncode(result)
47
48
49def test_compile_json():
50 result = check_subcommand('compile', '-kb', 'handwired/pytest/basic', '-km', 'default_json', '-n')
51 check_returncode(result)
52
53
54def test_flash():
55 result = check_subcommand('flash', '-kb', 'handwired/pytest/basic', '-km', 'default', '-n')
56 check_returncode(result)
57
58
59def test_flash_bootloaders():
60 result = check_subcommand('flash', '-b')
61 check_returncode(result, [1])
62
63
64def test_kle2json():
65 result = check_subcommand('kle2json', 'lib/python/qmk/tests/kle.txt', '-f')
66 check_returncode(result)
67 assert 'Wrote out' in result.stdout
68
69
70def test_doctor():
71 result = check_subcommand('doctor', '-n')
72 check_returncode(result, [0, 1])
73 assert 'QMK Doctor is checking your environment.' in result.stdout
74 assert 'QMK is ready to go' in result.stdout
75
76
77def test_hello():
78 result = check_subcommand('hello')
79 check_returncode(result)
80 assert 'Hello,' in result.stdout
81
82
83def test_format_python():
84 result = check_subcommand('format-python', '-n', '-a')
85 check_returncode(result)
86 assert 'Successfully formatted the python code.' in result.stdout
87
88
89def test_list_keyboards():
90 result = check_subcommand('list-keyboards')
91 check_returncode(result)
92 # check to see if a known keyboard is returned
93 # this will fail if handwired/pytest/basic is removed
94 assert 'handwired/pytest/basic' in result.stdout
95
96
97def test_list_keymaps():
98 result = check_subcommand('list-keymaps', '-kb', 'handwired/pytest/basic')
99 check_returncode(result)
100 assert 'default' in result.stdout
101 assert 'default_json' in result.stdout
102
103
104def test_list_keymaps_long():
105 result = check_subcommand('list-keymaps', '--keyboard', 'handwired/pytest/basic')
106 check_returncode(result)
107 assert 'default' in result.stdout
108 assert 'default_json' in result.stdout
109
110
111def test_list_keymaps_community():
112 result = check_subcommand('list-keymaps', '--keyboard', 'handwired/pytest/has_community')
113 check_returncode(result)
114 assert 'test' in result.stdout
115
116
117def test_list_keymaps_kb_only():
118 result = check_subcommand('list-keymaps', '-kb', 'contra')
119 check_returncode(result)
120 assert 'default' in result.stdout
121
122
123def test_list_keymaps_vendor_kb():
124 result = check_subcommand('list-keymaps', '-kb', 'ai03/lunar')
125 check_returncode(result)
126 assert 'default' in result.stdout
127
128
129def test_list_keymaps_vendor_kb_rev():
130 result = check_subcommand('list-keymaps', '-kb', 'kbdfans/kbd67/mkiirgb/v2')
131 check_returncode(result)
132 assert 'default' in result.stdout
133
134
135def test_list_keymaps_no_keyboard_found():
136 result = check_subcommand('list-keymaps', '-kb', 'asdfghjkl')
137 check_returncode(result, [2])
138 assert 'invalid keyboard_folder value' in result.stdout
139
140
141def test_json2c():
142 result = check_subcommand('json2c', 'keyboards/handwired/pytest/basic/keymaps/default_json/keymap.json')
143 check_returncode(result)
144 assert result.stdout == """#include QMK_KEYBOARD_H
145#if __has_include("keymap.h")
146# include "keymap.h"
147#endif
148
149
150/* THIS FILE WAS GENERATED!
151 *
152 * This file was generated by qmk json2c. You may or may not want to
153 * edit it directly.
154 */
155
156const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
157 [0] = LAYOUT_ortho_1x1(KC_A)
158};
159
160
161
162
163#ifdef OTHER_KEYMAP_C
164# include OTHER_KEYMAP_C
165#endif // OTHER_KEYMAP_C
166
167
168"""
169
170
171def test_json2c_macros():
172 result = check_subcommand("json2c", 'keyboards/handwired/pytest/macro/keymaps/default/keymap.json')
173 check_returncode(result)
174 assert 'LAYOUT_ortho_1x1(QK_MACRO_0)' in result.stdout
175 assert 'case QK_MACRO_0:' in result.stdout
176 assert 'SEND_STRING("Hello, World!"SS_TAP(X_ENTER));' in result.stdout
177
178
179def test_json2c_stdin():
180 result = check_subcommand_stdin('keyboards/handwired/pytest/basic/keymaps/default_json/keymap.json', 'json2c', '-')
181 check_returncode(result)
182 assert result.stdout == """#include QMK_KEYBOARD_H
183#if __has_include("keymap.h")
184# include "keymap.h"
185#endif
186
187
188/* THIS FILE WAS GENERATED!
189 *
190 * This file was generated by qmk json2c. You may or may not want to
191 * edit it directly.
192 */
193
194const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
195 [0] = LAYOUT_ortho_1x1(KC_A)
196};
197
198
199
200
201#ifdef OTHER_KEYMAP_C
202# include OTHER_KEYMAP_C
203#endif // OTHER_KEYMAP_C
204
205
206"""
207
208
209def test_json2c_no_json():
210 result = check_subcommand('json2c', 'keyboards/handwired/pytest/basic/keymaps/default/keymap.c')
211 check_returncode(result, [1])
212 assert 'Invalid JSON encountered' in result.stdout
213
214
215def test_info():
216 result = check_subcommand('info', '-kb', 'handwired/pytest/basic')
217 check_returncode(result)
218 assert 'Keyboard Name: pytest' in result.stdout
219 assert 'Processor: atmega32u4' in result.stdout
220 assert 'Layout:' not in result.stdout
221 assert 'k0' not in result.stdout
222
223
224def test_info_keyboard_render():
225 result = check_subcommand('info', '-kb', 'handwired/pytest/basic', '-l')
226 check_returncode(result)
227 assert 'Keyboard Name: pytest' in result.stdout
228 assert 'Processor: atmega32u4' in result.stdout
229 assert 'Layouts:' in result.stdout
230
231 if is_windows:
232 assert '| |' in result.stdout
233 else:
234 assert '│ │' in result.stdout
235
236
237def test_info_keymap_render():
238 result = check_subcommand('info', '-kb', 'handwired/pytest/basic', '-km', 'default_json')
239 check_returncode(result)
240 assert 'Keyboard Name: pytest' in result.stdout
241 assert 'Processor: atmega32u4' in result.stdout
242
243 if is_windows:
244 assert '|A |' in result.stdout
245 else:
246 assert '│A │' in result.stdout
247
248
249def test_info_matrix_render():
250 result = check_subcommand('info', '-kb', 'handwired/pytest/basic', '-m')
251 check_returncode(result)
252 assert 'Keyboard Name: pytest' in result.stdout
253 assert 'Processor: atmega32u4' in result.stdout
254 assert 'LAYOUT_ortho_1x1' in result.stdout
255
256 if is_windows:
257 assert '|0A|' in result.stdout
258 else:
259 assert '│0A│' in result.stdout
260
261 assert 'Matrix for "LAYOUT_ortho_1x1"' in result.stdout
262
263
264def test_c2json():
265 result = check_subcommand("c2json", "-kb", "handwired/pytest/basic", "-km", "default", "keyboards/handwired/pytest/basic/keymaps/default/keymap.c")
266 check_returncode(result)
267 assert result.stdout.strip() == '{"keyboard": "handwired/pytest/basic", "keymap": "default", "layout": "LAYOUT_ortho_1x1", "layers": [["KC_A"]]}'
268
269
270def test_c2json_stdin():
271 result = check_subcommand_stdin("keyboards/handwired/pytest/basic/keymaps/default/keymap.c", "c2json", "-kb", "handwired/pytest/basic", "-km", "default", "-")
272 check_returncode(result)
273 assert result.stdout.strip() == '{"keyboard": "handwired/pytest/basic", "keymap": "default", "layout": "LAYOUT_ortho_1x1", "layers": [["KC_A"]]}'
274
275
276def test_clean():
277 result = check_subcommand('clean', '-a')
278 check_returncode(result)
279 assert (result.stdout.count('done') == 2 and 'userspace' not in result.stdout) or (result.stdout.count('done') == 3 and 'userspace' in result.stdout)
280
281
282def test_generate_api():
283 result = check_subcommand('generate-api', '--dry-run', '--filter', 'handwired/pytest')
284 check_returncode(result)
285
286
287def test_generate_rgb_breathe_table():
288 result = check_subcommand("generate-rgb-breathe-table", "-c", "1.2", "-m", "127")
289 check_returncode(result)
290 assert 'Breathing center: 1.2' in result.stdout
291 assert 'Breathing max: 127' in result.stdout
292
293
294def test_generate_config_h():
295 result = check_subcommand('generate-config-h', '-kb', 'handwired/pytest/basic')
296 check_returncode(result)
297 assert '# define DEVICE_VER 0x0001' in result.stdout
298 assert '# define DIODE_DIRECTION COL2ROW' in result.stdout
299 assert '# define MANUFACTURER "none"' in result.stdout
300 assert '# define PRODUCT "pytest"' in result.stdout
301 assert '# define PRODUCT_ID 0x6465' in result.stdout
302 assert '# define VENDOR_ID 0xFEED' in result.stdout
303 assert '# define MATRIX_COLS 1' in result.stdout
304 assert '# define MATRIX_COL_PINS { F4 }' in result.stdout
305 assert '# define MATRIX_ROWS 1' in result.stdout
306 assert '# define MATRIX_ROW_PINS { F5 }' in result.stdout
307
308
309def test_generate_rules_mk():
310 result = check_subcommand('generate-rules-mk', '-kb', 'handwired/pytest/basic')
311 check_returncode(result)
312 assert 'BOOTLOADER ?= atmel-dfu' in result.stdout
313 assert 'MCU ?= atmega32u4' in result.stdout
314
315
316def test_generate_version_h():
317 result = check_subcommand('generate-version-h')
318 check_returncode(result)
319 assert '#define QMK_VERSION' in result.stdout
320
321
322def test_format_json_keyboard():
323 result = check_subcommand('format-json', '--format', 'keyboard', 'lib/python/qmk/tests/minimal_info.json')
324 check_returncode(result)
325 assert result.stdout == '{\n "keyboard_name": "tester",\n "maintainer": "qmk",\n "layouts": {\n "LAYOUT": {\n "layout": [\n {"label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0}\n ]\n }\n }\n}\n'
326
327
328def test_format_json_keymap():
329 result = check_subcommand('format-json', '--format', 'keymap', 'lib/python/qmk/tests/minimal_keymap.json')
330 check_returncode(result)
331 assert result.stdout == '{\n "version": 1,\n "keyboard": "handwired/pytest/basic",\n "keymap": "test",\n "layout": "LAYOUT_ortho_1x1",\n "layers": [\n [\n "KC_A"\n ]\n ]\n}\n'
332
333
334def test_format_json_keyboard_auto():
335 result = check_subcommand('format-json', '--format', 'auto', 'lib/python/qmk/tests/minimal_info.json')
336 check_returncode(result)
337 assert result.stdout == '{\n "keyboard_name": "tester",\n "maintainer": "qmk",\n "layouts": {\n "LAYOUT": {\n "layout": [\n {"label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0}\n ]\n }\n }\n}\n'
338
339
340def test_format_json_keymap_auto():
341 result = check_subcommand('format-json', '--format', 'auto', 'lib/python/qmk/tests/minimal_keymap.json')
342 check_returncode(result)
343 assert result.stdout == '{\n "keyboard": "handwired/pytest/basic",\n "keymap": "test",\n "layers": [\n ["KC_A"]\n ],\n "layout": "LAYOUT_ortho_1x1",\n "version": 1\n}\n'
344
345
346def test_find_exists():
347 result = check_subcommand('find', '-f', 'exists(rgb_matrix.split_count)', '-p', 'rgb_matrix.split_count')
348 check_returncode(result)
349 values = [s for s in result.stdout.splitlines() if 'rgb_matrix.split_count=' in s]
350 assert len(values) > 0
351 for s in values:
352 assert '=None' not in s
353 assert '=[' in s
354
355
356def test_find_absent():
357 result = check_subcommand('find', '-f', 'absent(rgb_matrix.split_count)', '-p', 'rgb_matrix.split_count')
358 check_returncode(result)
359 values = [s for s in result.stdout.splitlines() if 'rgb_matrix.split_count=' in s]
360 assert len(values) > 0
361 for s in values:
362 assert '=None' in s
363 assert '=[' not in s
364
365
366def test_find_length():
367 result = check_subcommand('find', '-f', 'length(matrix_pins.cols, 6)', '-p', 'matrix_pins.cols')
368 check_returncode(result)
369 values = [s for s in result.stdout.splitlines() if 'matrix_pins.cols=' in s]
370 assert len(values) > 0
371 for s in values:
372 assert s.count(',') == 5
373
374
375def test_find_contains():
376 result = check_subcommand('find', '-f', 'contains(matrix_pins.cols, B1)', '-p', 'matrix_pins.cols')
377 check_returncode(result)
378 values = [s for s in result.stdout.splitlines() if 'matrix_pins.cols=' in s]
379 assert len(values) > 0
380 for s in values:
381 assert "'B1'" in s
382
383
384def test_find_multiple_conditions():
385 # this is intended to match at least 'crkbd/rev1'
386 result = check_subcommand(
387 'find', '-f', 'exists(rgb_matrix.split_count)', '-f', 'contains(matrix_pins.cols, B1)', '-f', 'length(matrix_pins.cols, 6)', '-f', 'absent(eeprom.driver)', '-f', 'ws2812.pin == D3', '-p', 'rgb_matrix.split_count', '-p', 'matrix_pins.cols', '-p',
388 'eeprom.driver', '-p', 'ws2812.pin'
389 )
390 check_returncode(result)
391 rgb_matrix_split_count_values = [s for s in result.stdout.splitlines() if 'rgb_matrix.split_count=' in s]
392 assert len(rgb_matrix_split_count_values) > 0
393 for s in rgb_matrix_split_count_values:
394 assert '=None' not in s
395 assert '=[' in s
396 matrix_pins_cols_values = [s for s in result.stdout.splitlines() if 'matrix_pins.cols=' in s]
397 assert len(matrix_pins_cols_values) > 0
398 for s in matrix_pins_cols_values:
399 assert s.count(',') == 5
400 assert "'B1'" in s
401 eeprom_driver_values = [s for s in result.stdout.splitlines() if 'eeprom.driver=' in s]
402 assert len(eeprom_driver_values) > 0
403 for s in eeprom_driver_values:
404 assert '=None' in s
405 ws2812_pin_values = [s for s in result.stdout.splitlines() if 'ws2812.pin=' in s]
406 assert len(ws2812_pin_values) > 0
407 for s in ws2812_pin_values:
408 assert '=D3' in s
diff --git a/lib/python/qmk/tests/test_qmk_errors.py b/lib/python/qmk/tests/test_qmk_errors.py
new file mode 100644
index 0000000000..948e7ef741
--- /dev/null
+++ b/lib/python/qmk/tests/test_qmk_errors.py
@@ -0,0 +1,8 @@
1from qmk.errors import NoSuchKeyboardError
2
3
4def test_nosuchkeyboarderror():
5 try:
6 raise NoSuchKeyboardError("test message")
7 except NoSuchKeyboardError as e:
8 assert e.message == 'test message'
diff --git a/lib/python/qmk/tests/test_qmk_keymap.py b/lib/python/qmk/tests/test_qmk_keymap.py
new file mode 100644
index 0000000000..34360d3b6d
--- /dev/null
+++ b/lib/python/qmk/tests/test_qmk_keymap.py
@@ -0,0 +1,47 @@
1import qmk.keymap
2
3
4def test_generate_c_pytest_basic():
5 keymap_json = {
6 'keyboard': 'handwired/pytest/basic',
7 'layout': 'LAYOUT',
8 'layers': [['KC_A']],
9 'macros': None,
10 }
11 templ = qmk.keymap.generate_c(keymap_json)
12 assert templ == """#include QMK_KEYBOARD_H
13#if __has_include("keymap.h")
14# include "keymap.h"
15#endif
16
17
18/* THIS FILE WAS GENERATED!
19 *
20 * This file was generated by qmk json2c. You may or may not want to
21 * edit it directly.
22 */
23
24const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
25 [0] = LAYOUT(KC_A)
26};
27
28
29
30
31#ifdef OTHER_KEYMAP_C
32# include OTHER_KEYMAP_C
33#endif // OTHER_KEYMAP_C
34"""
35
36
37def test_generate_json_pytest_basic():
38 templ = qmk.keymap.generate_json('default', 'handwired/pytest/basic', 'LAYOUT', [['KC_A']])
39 assert templ == {"keyboard": "handwired/pytest/basic", "keymap": "default", "layout": "LAYOUT", "layers": [["KC_A"]]}
40
41
42def test_parse_keymap_c():
43 parsed_keymap_c = qmk.keymap.parse_keymap_c('keyboards/handwired/pytest/basic/keymaps/default/keymap.c')
44 assert parsed_keymap_c == {'layers': [{'name': '0', 'layout': 'LAYOUT_ortho_1x1', 'keycodes': ['KC_A']}]}
45
46
47# FIXME(skullydazed): Add a test for qmk.keymap.write that mocks up an FD.
diff --git a/lib/python/qmk/tests/test_qmk_path.py b/lib/python/qmk/tests/test_qmk_path.py
new file mode 100644
index 0000000000..cc068e39da
--- /dev/null
+++ b/lib/python/qmk/tests/test_qmk_path.py
@@ -0,0 +1,14 @@
1import os
2from pathlib import Path
3
4import qmk.path
5
6
7def test_keymap_pytest_basic():
8 path = qmk.path.keymap('handwired/pytest/basic', 'default')
9 assert path.samefile('keyboards/handwired/pytest/basic/keymaps/default')
10
11
12def test_normpath():
13 path = qmk.path.normpath('lib/python')
14 assert path.samefile(Path(os.environ['ORIG_CWD']) / 'lib/python')
diff --git a/lib/python/qmk/userspace.py b/lib/python/qmk/userspace.py
new file mode 100644
index 0000000000..881490f796
--- /dev/null
+++ b/lib/python/qmk/userspace.py
@@ -0,0 +1,217 @@
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from os import environ
4from pathlib import Path
5import json
6import jsonschema
7
8from milc import cli
9
10from qmk.json_schema import validate, json_load
11from qmk.json_encoders import UserspaceJSONEncoder
12
13
14def qmk_userspace_paths():
15 test_dirs = []
16
17 # If we're already in a directory with a qmk.json and a keyboards or layouts directory, interpret it as userspace
18 if environ.get('ORIG_CWD') is not None:
19 current_dir = Path(environ['ORIG_CWD'])
20 while len(current_dir.parts) > 1:
21 if (current_dir / 'qmk.json').is_file():
22 test_dirs.append(current_dir)
23 current_dir = current_dir.parent
24
25 # If we have a QMK_USERSPACE environment variable, use that
26 if environ.get('QMK_USERSPACE') is not None:
27 current_dir = Path(environ['QMK_USERSPACE']).expanduser()
28 if current_dir.is_dir():
29 test_dirs.append(current_dir)
30
31 # If someone has configured a directory, use that
32 if cli.config.user.overlay_dir is not None:
33 current_dir = Path(cli.config.user.overlay_dir).expanduser().resolve()
34 if current_dir.is_dir():
35 test_dirs.append(current_dir)
36
37 # remove duplicates while maintaining the current order
38 return list(dict.fromkeys(test_dirs))
39
40
41def qmk_userspace_validate(path):
42 # Construct a UserspaceDefs object to ensure it validates correctly
43 if (path / 'qmk.json').is_file():
44 UserspaceDefs(path / 'qmk.json')
45 return
46
47 # No qmk.json file found
48 raise FileNotFoundError('No qmk.json file found.')
49
50
51def detect_qmk_userspace():
52 # Iterate through all the detected userspace paths and return the first one that validates correctly
53 test_dirs = qmk_userspace_paths()
54 for test_dir in test_dirs:
55 try:
56 qmk_userspace_validate(test_dir)
57 return test_dir
58 except FileNotFoundError:
59 continue
60 except UserspaceValidationError:
61 continue
62 return None
63
64
65class UserspaceDefs:
66 def __init__(self, userspace_json: Path):
67 self.path = userspace_json
68 self.build_targets = []
69 json = json_load(userspace_json)
70
71 exception = UserspaceValidationError()
72 success = False
73
74 try:
75 validate(json, 'qmk.user_repo.v0') # `qmk.json` must have a userspace_version at minimum
76 except jsonschema.ValidationError as err:
77 exception.add('qmk.user_repo.v0', err)
78 raise exception
79
80 # Iterate through each version of the schema, starting with the latest and decreasing to v1
81 schema_versions = [
82 ('qmk.user_repo.v1_1', self.__load_v1_1), #
83 ('qmk.user_repo.v1', self.__load_v1) #
84 ]
85
86 for v in schema_versions:
87 schema = v[0]
88 loader = v[1]
89 try:
90 validate(json, schema)
91 loader(json)
92 success = True
93 break
94 except jsonschema.ValidationError as err:
95 exception.add(schema, err)
96
97 if not success:
98 raise exception
99
100 def save(self):
101 target_json = {
102 "userspace_version": "1.1", # Needs to match latest version
103 "build_targets": []
104 }
105
106 for e in self.build_targets:
107 if isinstance(e, dict):
108 entry = [e['keyboard'], e['keymap']]
109 if 'env' in e:
110 entry.append(e['env'])
111 target_json['build_targets'].append(entry)
112 elif isinstance(e, Path):
113 target_json['build_targets'].append(str(e.relative_to(self.path.parent)))
114
115 try:
116 # Ensure what we're writing validates against the latest version of the schema
117 validate(target_json, 'qmk.user_repo.v1_1')
118 except jsonschema.ValidationError as err:
119 cli.log.error(f'Could not save userspace file: {err}')
120 return False
121
122 # Only actually write out data if it changed
123 old_data = json.dumps(json.loads(self.path.read_text()), cls=UserspaceJSONEncoder, sort_keys=True)
124 new_data = json.dumps(target_json, cls=UserspaceJSONEncoder, sort_keys=True)
125 if old_data != new_data:
126 self.path.write_text(new_data)
127 cli.log.info(f'Saved userspace file to {self.path}.')
128 return True
129
130 def add_target(self, keyboard=None, keymap=None, build_env=None, json_path=None, do_print=True):
131 if json_path is not None:
132 # Assume we're adding a json filename/path
133 json_path = Path(json_path)
134 if json_path not in self.build_targets:
135 self.build_targets.append(json_path)
136 if do_print:
137 cli.log.info(f'Added {json_path} to userspace build targets.')
138 else:
139 cli.log.info(f'{json_path} is already a userspace build target.')
140
141 elif keyboard is not None and keymap is not None:
142 # Both keyboard/keymap specified
143 e = {"keyboard": keyboard, "keymap": keymap}
144 if build_env is not None:
145 e['env'] = build_env
146 if e not in self.build_targets:
147 self.build_targets.append(e)
148 if do_print:
149 cli.log.info(f'Added {keyboard}:{keymap} to userspace build targets.')
150 else:
151 if do_print:
152 cli.log.info(f'{keyboard}:{keymap} is already a userspace build target.')
153
154 def remove_target(self, keyboard=None, keymap=None, build_env=None, json_path=None, do_print=True):
155 if json_path is not None:
156 # Assume we're removing a json filename/path
157 json_path = Path(json_path)
158 if json_path in self.build_targets:
159 self.build_targets.remove(json_path)
160 if do_print:
161 cli.log.info(f'Removed {json_path} from userspace build targets.')
162 else:
163 cli.log.info(f'{json_path} is not a userspace build target.')
164
165 elif keyboard is not None and keymap is not None:
166 # Both keyboard/keymap specified
167 e = {"keyboard": keyboard, "keymap": keymap}
168 if build_env is not None:
169 e['env'] = build_env
170 if e in self.build_targets:
171 self.build_targets.remove(e)
172 if do_print:
173 cli.log.info(f'Removed {keyboard}:{keymap} from userspace build targets.')
174 else:
175 if do_print:
176 cli.log.info(f'{keyboard}:{keymap} is not a userspace build target.')
177
178 def __load_v1(self, json):
179 for e in json['build_targets']:
180 self.__load_v1_target(e)
181
182 def __load_v1_1(self, json):
183 for e in json['build_targets']:
184 self.__load_v1_1_target(e)
185
186 def __load_v1_target(self, e):
187 if isinstance(e, list) and len(e) == 2:
188 self.add_target(keyboard=e[0], keymap=e[1], do_print=False)
189 if isinstance(e, str):
190 p = self.path.parent / e
191 if p.exists() and p.suffix == '.json':
192 self.add_target(json_path=p, do_print=False)
193
194 def __load_v1_1_target(self, e):
195 # v1.1 adds support for a third item in the build target tuple; kvp's for environment
196 if isinstance(e, list) and len(e) == 3:
197 self.add_target(keyboard=e[0], keymap=e[1], build_env=e[2], do_print=False)
198 else:
199 self.__load_v1_target(e)
200
201
202class UserspaceValidationError(Exception):
203 def __init__(self, *args, **kwargs):
204 super().__init__(*args, **kwargs)
205 self.__exceptions = []
206
207 def __str__(self):
208 return self.message
209
210 @property
211 def exceptions(self):
212 return self.__exceptions
213
214 def add(self, schema, exception):
215 self.__exceptions.append((schema, exception))
216 errorlist = "\n\n".join([f"{schema}: {exception}" for schema, exception in self.__exceptions])
217 self.message = f'Could not validate against any version of the userspace schema. Errors:\n\n{errorlist}'
diff --git a/lib/python/qmk/util.py b/lib/python/qmk/util.py
new file mode 100644
index 0000000000..6da684a577
--- /dev/null
+++ b/lib/python/qmk/util.py
@@ -0,0 +1,108 @@
1"""Utility functions.
2"""
3import contextlib
4import multiprocessing
5import sys
6import re
7
8from milc import cli
9
10TRIPLET_PATTERN = re.compile(r'^(\d+)\.(\d+)\.(\d+)')
11
12maybe_exit_should_exit = True
13maybe_exit_reraise = False
14
15
16# Controls whether or not early `exit()` calls should be made
17def maybe_exit(rc):
18 if maybe_exit_should_exit:
19 sys.exit(rc)
20 if maybe_exit_reraise:
21 e = sys.exc_info()[1]
22 if e:
23 raise e
24
25
26def maybe_exit_config(should_exit: bool = True, should_reraise: bool = False):
27 global maybe_exit_should_exit
28 global maybe_exit_reraise
29 maybe_exit_should_exit = should_exit
30 maybe_exit_reraise = should_reraise
31
32
33def truthy(value, value_if_unknown=False):
34 """Returns True if the value is truthy, False otherwise.
35
36 Deals with:
37 True: 1, true, t, yes, y, on
38 False: 0, false, f, no, n, off
39 """
40 if value in {False, True}:
41 return bool(value)
42
43 test_value = str(value).strip().lower()
44
45 if test_value in {"1", "true", "t", "yes", "y", "on"}:
46 return True
47
48 if test_value in {"0", "false", "f", "no", "n", "off"}:
49 return False
50
51 return value_if_unknown
52
53
54@contextlib.contextmanager
55def parallelize():
56 """Returns a function that can be used in place of a map() call.
57
58 Attempts to use `mpire`, falling back to `multiprocessing` if it's not
59 available. If parallelization is not requested, returns the original map()
60 function.
61 """
62
63 # Work out if we've already got a config value for parallel searching
64 if cli.config.user.parallel_search is None:
65 parallel_search = True
66 else:
67 parallel_search = cli.config.user.parallel_search
68
69 # Non-parallel searches use `map()`
70 if not parallel_search:
71 yield map
72 return
73
74 # Prefer mpire's `WorkerPool` if it's available
75 with contextlib.suppress(ImportError):
76 from mpire import WorkerPool
77 from mpire.utils import make_single_arguments
78 with WorkerPool() as pool:
79
80 def _worker(func, *args):
81 # Ensure we don't unpack tuples -- mpire's `WorkerPool` tries to do so normally so we tell it not to.
82 for r in pool.imap_unordered(func, make_single_arguments(*args, generator=False), progress_bar=True):
83 yield r
84
85 yield _worker
86 return
87
88 # Otherwise fall back to multiprocessing's `Pool`
89 with multiprocessing.Pool() as pool:
90 yield pool.imap_unordered
91
92
93def parallel_map(*args, **kwargs):
94 """Effectively runs `map()` but executes it in parallel if necessary.
95 """
96 with parallelize() as map_fn:
97 # This needs to be enclosed in a `list()` as some implementations return
98 # a generator function, which means the scope of the pool is closed off
99 # before the results are returned. Returning a list ensures results are
100 # materialised before any worker pool is shut down.
101 return list(map_fn(*args, **kwargs))
102
103
104def triplet_to_bcd(ver: str):
105 m = TRIPLET_PATTERN.match(ver)
106 if not m:
107 return '0x00000000'
108 return f'0x{int(m.group(1)):02d}{int(m.group(2)):02d}{int(m.group(3)):04d}'