diff options
Diffstat (limited to 'lib/python')
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 | |||
| 4 | import hjson | ||
| 5 | from decimal import Decimal | ||
| 6 | |||
| 7 | |||
| 8 | class 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 | ||
| 3 | import json | ||
| 4 | import shutil | ||
| 5 | from typing import Dict, List, Union | ||
| 6 | from pathlib import Path | ||
| 7 | from dotty_dict import dotty, Dotty | ||
| 8 | from milc import cli | ||
| 9 | from qmk.constants import QMK_FIRMWARE, INTERMEDIATE_OUTPUT_PREFIX, HAS_QMK_USERSPACE, QMK_USERSPACE | ||
| 10 | from qmk.commands import find_make, get_make_parallel_args, parse_configurator_json | ||
| 11 | from qmk.keyboard import keyboard_folder | ||
| 12 | from qmk.info import keymap_json | ||
| 13 | from qmk.keymap import locate_keymap | ||
| 14 | from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace, unix_style_path | ||
| 15 | from 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. | ||
| 18 | TARGET_FILENAME_MODIFIERS = ['FORCE_LAYOUT', 'CONVERT_TO'] | ||
| 19 | |||
| 20 | |||
| 21 | class 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 | |||
| 181 | class 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 | |||
| 217 | class 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 | """ | ||
| 3 | from pygments.lexers.c_cpp import CLexer | ||
| 4 | from pygments.token import Token | ||
| 5 | from pygments import lex | ||
| 6 | from itertools import islice | ||
| 7 | from pathlib import Path | ||
| 8 | import re | ||
| 9 | |||
| 10 | from milc import cli | ||
| 11 | |||
| 12 | from qmk.comment_remover import comment_remover | ||
| 13 | |||
| 14 | default_key_entry = {'x': -1, 'y': 0} | ||
| 15 | single_comment_regex = re.compile(r'\s+/[/*].*$') | ||
| 16 | multi_comment_regex = re.compile(r'/\*(.|\n)*?\*/', re.MULTILINE) | ||
| 17 | layout_macro_define_regex = re.compile(r'^#\s*define') | ||
| 18 | |||
| 19 | |||
| 20 | def _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 | |||
| 27 | def 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 | |||
| 35 | def strip_line_comment(string): | ||
| 36 | """Removes comments from a single line string. | ||
| 37 | """ | ||
| 38 | return single_comment_regex.sub('', string) | ||
| 39 | |||
| 40 | |||
| 41 | def strip_multiline_comment(string): | ||
| 42 | """Removes comments from a single line string. | ||
| 43 | """ | ||
| 44 | return multi_comment_regex.sub('', string) | ||
| 45 | |||
| 46 | |||
| 47 | def 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 | |||
| 61 | def 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 | |||
| 114 | def 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 | |||
| 156 | def _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 | |||
| 168 | def _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 | |||
| 178 | def _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 | |||
| 198 | def _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 | |||
| 220 | def _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 | |||
| 237 | def _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 | |||
| 300 | def 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 | |||
| 3 | We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup. | ||
| 4 | """ | ||
| 5 | import os | ||
| 6 | import platform | ||
| 7 | import platformdirs | ||
| 8 | import shlex | ||
| 9 | import sys | ||
| 10 | from importlib.util import find_spec | ||
| 11 | from pathlib import Path | ||
| 12 | from subprocess import run | ||
| 13 | |||
| 14 | from milc import cli, __VERSION__ | ||
| 15 | from milc.questions import yesno | ||
| 16 | |||
| 17 | |||
| 18 | def _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. | ||
| 31 | QMK_DISTRIB_DIR = Path(os.environ.get('QMK_DISTRIB_DIR', _get_default_distrib_path())) | ||
| 32 | if 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 | ||
| 36 | if 'QMK_PATH_PREFIX' in os.environ: | ||
| 37 | os.environ['PATH'] = os.environ['QMK_PATH_PREFIX'] + os.pathsep + os.environ['PATH'] | ||
| 38 | |||
| 39 | import_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 | |||
| 48 | safe_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 | |||
| 57 | subcommands = [ | ||
| 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 | |||
| 121 | def _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 | |||
| 141 | def _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 | |||
| 152 | def _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 | |||
| 187 | def _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 | |||
| 201 | def _yesno(*args): | ||
| 202 | """Wrapper to only prompt if interactive | ||
| 203 | """ | ||
| 204 | return sys.stdout.isatty() and yesno(*args) | ||
| 205 | |||
| 206 | |||
| 207 | def _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 | |||
| 235 | if 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 | |||
| 239 | milc_version = __VERSION__.split('.') | ||
| 240 | |||
| 241 | if 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 | ||
| 248 | python_dir = os.path.dirname(sys.executable) | ||
| 249 | |||
| 250 | if 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 | ||
| 254 | msg_install = f'\nPlease run `{sys.executable} -m pip install -r %s` to install required python dependencies.' | ||
| 255 | args = sys.argv[1:] | ||
| 256 | while args and args[0][0] == '-': | ||
| 257 | del args[0] | ||
| 258 | |||
| 259 | safe_command = args and args[0] in safe_commands | ||
| 260 | |||
| 261 | if 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 | ||
| 280 | for 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 | |||
| 3 | World domination secret weapon. | ||
| 4 | """ | ||
| 5 | from milc import cli | ||
| 6 | from milc.subcommand import config | ||
| 7 | |||
| 8 | |||
| 9 | @cli.subcommand('QMK Bux miner.', hidden=True) | ||
| 10 | def 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 | """ | ||
| 3 | import re | ||
| 4 | import json | ||
| 5 | |||
| 6 | from argcomplete.completers import FilesCompleter | ||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | import qmk.path | ||
| 10 | from qmk.json_encoders import InfoJSONEncoder | ||
| 11 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 12 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 13 | from qmk.keymap import locate_keymap, find_keymap_from_dir, generate_json, c2json as c2json_impl | ||
| 14 | from qmk.errors import CppError | ||
| 15 | from 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 | ||
| 27 | def 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 | """ | ||
| 3 | import sys | ||
| 4 | import os | ||
| 5 | import subprocess | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.path import under_qmk_firmware | ||
| 10 | |||
| 11 | |||
| 12 | @cli.subcommand('Go to QMK Home') | ||
| 13 | def 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 | """ | ||
| 3 | import re | ||
| 4 | import sys | ||
| 5 | import os | ||
| 6 | |||
| 7 | from qmk.constants import QMK_FIRMWARE | ||
| 8 | from qmk.path import normpath | ||
| 9 | from milc import cli | ||
| 10 | |||
| 11 | |||
| 12 | def eprint(*args, **kwargs): | ||
| 13 | print(*args, file=sys.stderr, **kwargs) | ||
| 14 | |||
| 15 | |||
| 16 | file_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 | |||
| 42 | def 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 | |||
| 57 | def 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 | |||
| 79 | def 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 | |||
| 89 | def 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 | |||
| 99 | def 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') | ||
| 116 | def 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 | """ | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | from qmk.keyboard import keyboard_folder, keyboard_alias_definitions | ||
| 6 | |||
| 7 | |||
| 8 | def _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 | |||
| 15 | def _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 | |||
| 28 | def _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) | ||
| 44 | def 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 | """ | ||
| 3 | from subprocess import DEVNULL | ||
| 4 | |||
| 5 | from qmk.commands import find_make | ||
| 6 | from 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.') | ||
| 11 | def 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 | |||
| 3 | You can compile a keymap already in the repo or using a QMK Configurator export. | ||
| 4 | """ | ||
| 5 | from argcomplete.completers import FilesCompleter | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | import qmk.path | ||
| 10 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 11 | from qmk.commands import build_environment | ||
| 12 | from qmk.keyboard import keyboard_completer, keyboard_folder_or_all, is_all_keyboards | ||
| 13 | from qmk.keymap import keymap_completer, locate_keymap | ||
| 14 | from 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 | ||
| 29 | def 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 | """ | ||
| 3 | import shutil | ||
| 4 | from qmk.docs import prepare_docs_build_area, run_docs_command | ||
| 5 | |||
| 6 | from 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) | ||
| 12 | def 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 | |||
| 3 | Check out the user's QMK environment and make sure it's ready to compile. | ||
| 4 | """ | ||
| 5 | from .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 | """ | ||
| 3 | from enum import Enum | ||
| 4 | import shutil | ||
| 5 | from subprocess import DEVNULL, TimeoutExpired | ||
| 6 | from tempfile import TemporaryDirectory | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | from milc import cli | ||
| 10 | from qmk import submodules | ||
| 11 | from qmk.commands import find_make | ||
| 12 | |||
| 13 | |||
| 14 | class CheckStatus(Enum): | ||
| 15 | OK = 1 | ||
| 16 | WARNING = 2 | ||
| 17 | ERROR = 3 | ||
| 18 | |||
| 19 | |||
| 20 | WHICH_MAKE = Path(find_make()).name | ||
| 21 | |||
| 22 | ESSENTIAL_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 | |||
| 39 | def _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 | |||
| 47 | def _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 | |||
| 55 | def _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 | |||
| 63 | def _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 | |||
| 74 | def _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 | |||
| 84 | def _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 | |||
| 114 | def _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 | |||
| 124 | def _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 | |||
| 154 | def _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 | |||
| 164 | def _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 | |||
| 172 | def _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 | |||
| 180 | def 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 | |||
| 205 | def 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 | |||
| 232 | def 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 | |||
| 244 | def 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 | |||
| 253 | def 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 | |||
| 270 | def 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 | """ | ||
| 3 | import platform | ||
| 4 | import shutil | ||
| 5 | from pathlib import Path | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.constants import QMK_FIRMWARE, BOOTLOADER_VIDS_PIDS | ||
| 10 | from .check import CheckStatus, release_info | ||
| 11 | |||
| 12 | |||
| 13 | def _is_wsl(): | ||
| 14 | return 'microsoft' in platform.uname().release.lower() | ||
| 15 | |||
| 16 | |||
| 17 | def _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 | |||
| 33 | def _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 | |||
| 45 | def _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 | |||
| 56 | def 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 | |||
| 114 | def check_systemd(): | ||
| 115 | """Check if it's a systemd system | ||
| 116 | """ | ||
| 117 | return bool(shutil.which("systemctl")) | ||
| 118 | |||
| 119 | |||
| 120 | def 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 | |||
| 134 | def 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 @@ | |||
| 1 | import platform | ||
| 2 | |||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | from .check import CheckStatus | ||
| 6 | |||
| 7 | |||
| 8 | def 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 | |||
| 3 | Check out the user's QMK environment and make sure it's ready to compile. | ||
| 4 | """ | ||
| 5 | import platform | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | from milc.questions import yesno | ||
| 9 | |||
| 10 | from qmk import submodules | ||
| 11 | from qmk.constants import QMK_FIRMWARE, QMK_FIRMWARE_UPSTREAM, QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 12 | from .check import CheckStatus, check_binaries, check_binary_versions, check_submodules | ||
| 13 | from 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 | ||
| 14 | from qmk.commands import in_virtualenv | ||
| 15 | from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError | ||
| 16 | |||
| 17 | |||
| 18 | def 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 | |||
| 72 | def 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 | |||
| 91 | def 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 | |||
| 131 | def 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 | |||
| 149 | def 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') | ||
| 171 | def 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 @@ | |||
| 1 | import platform | ||
| 2 | |||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | from .check import CheckStatus, release_info | ||
| 6 | |||
| 7 | |||
| 8 | def 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 | """ | ||
| 3 | import os | ||
| 4 | from milc import cli | ||
| 5 | from qmk.search import filter_help, search_keymap_targets | ||
| 6 | from 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.') | ||
| 21 | def 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 | |||
| 3 | You can compile a keymap already in the repo or using a QMK Configurator export. | ||
| 4 | A bootloader must be specified. | ||
| 5 | """ | ||
| 6 | from argcomplete.completers import FilesCompleter | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | from milc import cli | ||
| 10 | |||
| 11 | import qmk.path | ||
| 12 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 13 | from qmk.commands import build_environment | ||
| 14 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 15 | from qmk.keymap import keymap_completer, locate_keymap | ||
| 16 | from qmk.flashers import flasher | ||
| 17 | from qmk.build_targets import KeyboardKeymapBuildTarget, JsonKeymapBuildTarget | ||
| 18 | |||
| 19 | |||
| 20 | def _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 | |||
| 46 | def _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 | ||
| 73 | def 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 | """ | ||
| 3 | from shutil import which | ||
| 4 | from subprocess import CalledProcessError, DEVNULL, Popen, PIPE | ||
| 5 | |||
| 6 | from argcomplete.completers import FilesCompleter | ||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.path import normpath | ||
| 10 | from qmk.c_parse import c_source_files | ||
| 11 | |||
| 12 | c_file_suffixes = ('c', 'h', 'cpp', 'hpp') | ||
| 13 | core_dirs = ('drivers', 'quantum', 'tests', 'tmk_core', 'platforms', 'modules') | ||
| 14 | ignored = ('tmk_core/protocol/usb_hid', 'platforms/chibios/boards') | ||
| 15 | |||
| 16 | |||
| 17 | def 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 | |||
| 23 | def 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 | |||
| 35 | def 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 | |||
| 52 | def 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 | |||
| 73 | def 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) | ||
| 99 | def 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 | |||
| 3 | Spits out a JSON file formatted with one of QMK's formatters. | ||
| 4 | """ | ||
| 5 | import json | ||
| 6 | |||
| 7 | from jsonschema import ValidationError | ||
| 8 | from milc import cli | ||
| 9 | |||
| 10 | from qmk.info import info_json | ||
| 11 | from qmk.json_schema import json_load, validate | ||
| 12 | from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder, UserspaceJSONEncoder, CommunityModuleJSONEncoder | ||
| 13 | from qmk.path import normpath | ||
| 14 | |||
| 15 | |||
| 16 | def _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 | |||
| 52 | def _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) | ||
| 77 | def 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 | """ | ||
| 3 | from subprocess import CalledProcessError, DEVNULL | ||
| 4 | |||
| 5 | from milc import cli | ||
| 6 | |||
| 7 | from qmk.path import normpath | ||
| 8 | |||
| 9 | py_file_suffixes = ('py',) | ||
| 10 | py_dirs = ['lib/python', 'util/ci'] | ||
| 11 | |||
| 12 | |||
| 13 | def 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 | |||
| 25 | def 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) | ||
| 41 | def 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 | """ | ||
| 3 | from itertools import islice | ||
| 4 | from subprocess import DEVNULL | ||
| 5 | |||
| 6 | from milc import cli | ||
| 7 | |||
| 8 | from qmk.path import normpath | ||
| 9 | |||
| 10 | |||
| 11 | def _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 | |||
| 18 | def 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) | ||
| 32 | def 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 | """ | ||
| 3 | from pathlib import Path | ||
| 4 | import shutil | ||
| 5 | import json | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | import qmk.path | ||
| 10 | from qmk.datetime import current_datetime | ||
| 11 | from qmk.info import info_json | ||
| 12 | from qmk.json_schema import json_load | ||
| 13 | from qmk.keymap import list_keymaps | ||
| 14 | from qmk.keyboard import find_readme, list_keyboards, keyboard_alias_definitions | ||
| 15 | from qmk.keycodes import load_spec, list_versions, list_languages | ||
| 16 | |||
| 17 | DATA_PATH = Path('data') | ||
| 18 | TEMPLATE_PATH = DATA_PATH / 'templates/api/' | ||
| 19 | BUILD_API_PATH = Path('.build/api_data/') | ||
| 20 | |||
| 21 | |||
| 22 | def _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 | |||
| 39 | def _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 | |||
| 59 | def _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 | |||
| 79 | def _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) | ||
| 95 | def 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. | ||
| 15 | This 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 | ||
| 17 | program and pass it as the first argument like: | ||
| 18 | $ qmk generate-autocorrect-data autocorrect_dict.txt | ||
| 19 | Each 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. | ||
| 21 | Example: | ||
| 22 | :thier -> their | ||
| 23 | fitler -> filter | ||
| 24 | lenght -> length | ||
| 25 | ouput -> output | ||
| 26 | widht -> width | ||
| 27 | For full documentation, see QMK Docs | ||
| 28 | """ | ||
| 29 | |||
| 30 | import textwrap | ||
| 31 | from typing import Any, Dict, Iterator, List, Tuple | ||
| 32 | |||
| 33 | from milc import cli | ||
| 34 | |||
| 35 | from qmk.commands import dump_lines | ||
| 36 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 37 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 38 | from qmk.keymap import keymap_completer, locate_keymap | ||
| 39 | from qmk.path import normpath | ||
| 40 | from qmk.util import maybe_exit | ||
| 41 | |||
| 42 | KC_A = 4 | ||
| 43 | KC_SPC = 0x2c | ||
| 44 | KC_QUOT = 0x34 | ||
| 45 | |||
| 46 | TYPO_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 | |||
| 53 | def 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 | |||
| 110 | def 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 | |||
| 127 | def 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 | |||
| 148 | def 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 | |||
| 168 | def 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 | |||
| 235 | def 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 | |||
| 244 | def typo_len(e: Tuple[str, str]) -> int: | ||
| 245 | return len(e[0]) | ||
| 246 | |||
| 247 | |||
| 248 | def 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.') | ||
| 258 | def 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 @@ | |||
| 1 | import contextlib | ||
| 2 | from argcomplete.completers import FilesCompleter | ||
| 3 | from pathlib import Path | ||
| 4 | |||
| 5 | from milc import cli | ||
| 6 | |||
| 7 | import qmk.path | ||
| 8 | from qmk.info import get_modules | ||
| 9 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 10 | from qmk.commands import dump_lines | ||
| 11 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE | ||
| 12 | from qmk.community_modules import module_api_list, load_module_jsons, find_module_path | ||
| 13 | |||
| 14 | |||
| 15 | @contextlib.contextmanager | ||
| 16 | def _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 | |||
| 24 | def _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 | |||
| 33 | def _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 | |||
| 59 | def _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 | |||
| 70 | def _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 | |||
| 111 | def _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 | |||
| 134 | def _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 | |||
| 143 | def _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.') | ||
| 178 | def 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.') | ||
| 202 | def 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.') | ||
| 255 | def 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 | |||
| 283 | def _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.') | ||
| 305 | def 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.') | ||
| 316 | def 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.') | ||
| 327 | def 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.') | ||
| 338 | def 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 @@ | |||
| 1 | from 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.') | ||
| 7 | def 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 | """ | ||
| 3 | from pathlib import Path | ||
| 4 | from dotty_dict import dotty | ||
| 5 | |||
| 6 | from argcomplete.completers import FilesCompleter | ||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.info import info_json | ||
| 10 | from qmk.json_schema import json_load | ||
| 11 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 12 | from qmk.commands import dump_lines, parse_configurator_json | ||
| 13 | from qmk.path import normpath, FileType | ||
| 14 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 15 | |||
| 16 | |||
| 17 | def 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 | |||
| 30 | def 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 | |||
| 42 | def 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 | |||
| 50 | def 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 | |||
| 67 | def 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 | |||
| 75 | def 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 | |||
| 115 | def 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 | |||
| 139 | def 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 | |||
| 158 | def 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) | ||
| 172 | def 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 | """ | ||
| 3 | import os | ||
| 4 | import re | ||
| 5 | from pathlib import Path | ||
| 6 | from subprocess import DEVNULL | ||
| 7 | |||
| 8 | from milc import cli | ||
| 9 | |||
| 10 | cache_timeout = 7 * 86400 | ||
| 11 | fix_expr = re.compile(r'fix', flags=re.IGNORECASE) | ||
| 12 | clean1_expr = re.compile(r'\[(develop|keyboard|keymap|core|cli|bug|docs|feature)\]', flags=re.IGNORECASE) | ||
| 13 | clean2_expr = re.compile(r'^(develop|keyboard|keymap|core|cli|bug|docs|feature):', flags=re.IGNORECASE) | ||
| 14 | |||
| 15 | ignored_titles = ["Format code according to conventions"] | ||
| 16 | |||
| 17 | |||
| 18 | def _is_ignored(title): | ||
| 19 | for ignore in ignored_titles: | ||
| 20 | if ignore in title: | ||
| 21 | return True | ||
| 22 | return False | ||
| 23 | |||
| 24 | |||
| 25 | def _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 | |||
| 34 | def _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 | |||
| 45 | def _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) | ||
| 57 | def 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 | """ | ||
| 3 | from dotty_dict import dotty | ||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | from qmk.decorators import automagic_keyboard | ||
| 7 | from qmk.info import info_json | ||
| 8 | from qmk.path import is_keyboard, normpath | ||
| 9 | from qmk.keyboard import keyboard_completer | ||
| 10 | from qmk.commands import dump_lines | ||
| 11 | from 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 | ||
| 19 | def 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 | """ | ||
| 3 | import shutil | ||
| 4 | from qmk.docs import prepare_docs_build_area, run_docs_command, BUILD_DOCS_PATH | ||
| 5 | |||
| 6 | from 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) | ||
| 11 | def 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 | |||
| 3 | Compile an info.json for a particular keyboard and pretty-print it. | ||
| 4 | """ | ||
| 5 | import json | ||
| 6 | |||
| 7 | from argcomplete.completers import FilesCompleter | ||
| 8 | from jsonschema import Draft202012Validator, RefResolver, validators | ||
| 9 | from milc import cli | ||
| 10 | from pathlib import Path | ||
| 11 | |||
| 12 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 13 | from qmk.info import info_json | ||
| 14 | from qmk.json_encoders import InfoJSONEncoder | ||
| 15 | from qmk.json_schema import compile_schema_store | ||
| 16 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 17 | from qmk.path import is_keyboard, normpath | ||
| 18 | |||
| 19 | |||
| 20 | def 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 | |||
| 36 | def 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 | ||
| 55 | def 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 | """ | ||
| 3 | import bisect | ||
| 4 | import dataclasses | ||
| 5 | from typing import Optional | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.info import info_json | ||
| 10 | from qmk.commands import dump_lines | ||
| 11 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 12 | from qmk.path import normpath | ||
| 13 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, JOYSTICK_AXES | ||
| 14 | |||
| 15 | |||
| 16 | def _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 | |||
| 28 | def _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 | |||
| 69 | def _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 | |||
| 99 | def _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 | ||
| 136 | class 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 | |||
| 170 | class 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 | |||
| 207 | def _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) | ||
| 260 | def 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 | """ | ||
| 3 | from pathlib import Path | ||
| 4 | |||
| 5 | from milc import cli | ||
| 6 | |||
| 7 | from qmk.path import normpath | ||
| 8 | from qmk.info import info_json | ||
| 9 | from qmk.commands import dump_lines | ||
| 10 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 11 | from qmk.constants import COL_LETTERS, ROW_LETTERS, GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 12 | |||
| 13 | |||
| 14 | def _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 | |||
| 68 | def _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) | ||
| 102 | def 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 | """ | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 6 | from qmk.commands import dump_lines | ||
| 7 | from qmk.path import normpath | ||
| 8 | from qmk.keycodes import load_spec | ||
| 9 | |||
| 10 | |||
| 11 | def _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 | |||
| 21 | def _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 | |||
| 34 | def _render_label(label): | ||
| 35 | label = label.replace("\\", "(backslash)") | ||
| 36 | return label | ||
| 37 | |||
| 38 | |||
| 39 | def _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 | |||
| 52 | def _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 | |||
| 69 | def _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 | |||
| 106 | def _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 | |||
| 128 | def _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) | ||
| 146 | def 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) | ||
| 169 | def 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 @@ | |||
| 1 | from argcomplete.completers import FilesCompleter | ||
| 2 | |||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | import qmk.path | ||
| 6 | from qmk.commands import dump_lines | ||
| 7 | from qmk.commands import parse_configurator_json | ||
| 8 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 9 | |||
| 10 | |||
| 11 | def _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.') | ||
| 38 | def 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 | """ | ||
| 3 | from pathlib import Path | ||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | from argcomplete.completers import FilesCompleter | ||
| 7 | |||
| 8 | from qmk.commands import dump_lines | ||
| 9 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 10 | from qmk.keymap import keymap_completer, locate_keymap | ||
| 11 | from 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) | ||
| 20 | def 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 | """ | ||
| 3 | import math | ||
| 4 | from argparse import ArgumentTypeError | ||
| 5 | |||
| 6 | from milc import cli | ||
| 7 | |||
| 8 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 9 | from qmk.commands import dump_lines | ||
| 10 | from qmk.path import normpath | ||
| 11 | |||
| 12 | |||
| 13 | def 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 | |||
| 21 | def 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 | |||
| 29 | def _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 | |||
| 54 | const uint8_t PROGMEM rgblight_effect_breathe_table[] = {{ | ||
| 55 | {2} | ||
| 56 | }}; | ||
| 57 | |||
| 58 | static 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.') | ||
| 68 | def 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 | """ | ||
| 3 | from pathlib import Path | ||
| 4 | from dotty_dict import dotty | ||
| 5 | |||
| 6 | from argcomplete.completers import FilesCompleter | ||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.info import info_json | ||
| 10 | from qmk.json_schema import json_load | ||
| 11 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 12 | from qmk.commands import dump_lines, parse_configurator_json | ||
| 13 | from qmk.path import normpath, FileType | ||
| 14 | from qmk.constants import GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE | ||
| 15 | |||
| 16 | |||
| 17 | def 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 | |||
| 23 | def 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 | |||
| 49 | def 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) | ||
| 64 | def 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 | """ | ||
| 3 | from time import strftime | ||
| 4 | |||
| 5 | from milc import cli | ||
| 6 | |||
| 7 | from qmk.path import normpath | ||
| 8 | from qmk.commands import dump_lines | ||
| 9 | from qmk.git import git_get_qmk_hash, git_get_version, git_is_dirty | ||
| 10 | from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE | ||
| 11 | from qmk.util import triplet_to_bcd | ||
| 12 | |||
| 13 | TIME_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) | ||
| 21 | def 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 @@ | |||
| 1 | import shutil | ||
| 2 | from pathlib import Path | ||
| 3 | |||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | from qmk import submodules | ||
| 7 | |||
| 8 | REMOVE_DIRS = [ | ||
| 9 | 'lib/ugfx', | ||
| 10 | 'lib/chibios-contrib/ext/mcux-sdk', | ||
| 11 | ] | ||
| 12 | |||
| 13 | IGNORE_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.') | ||
| 26 | def 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 | |||
| 3 | This is an example QMK CLI script. | ||
| 4 | """ | ||
| 5 | from 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) | ||
| 10 | def 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 @@ | |||
| 1 | from milc import cli | ||
| 2 | |||
| 3 | from qmk.importers import import_kbfirmware as _import_kbfirmware | ||
| 4 | from qmk.path import FileType | ||
| 5 | from 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') | ||
| 10 | def 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 @@ | |||
| 1 | from milc import cli | ||
| 2 | |||
| 3 | from qmk.importers import import_keyboard as _import_keyboard | ||
| 4 | from qmk.path import FileType | ||
| 5 | from 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') | ||
| 10 | def 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 @@ | |||
| 1 | from milc import cli | ||
| 2 | |||
| 3 | from qmk.importers import import_keymap as _import_keymap | ||
| 4 | from qmk.path import FileType | ||
| 5 | from 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') | ||
| 10 | def 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 | |||
| 3 | Compile an info.json for a particular keyboard and pretty-print it. | ||
| 4 | """ | ||
| 5 | import sys | ||
| 6 | import json | ||
| 7 | |||
| 8 | from milc import cli | ||
| 9 | |||
| 10 | from qmk.json_encoders import InfoJSONEncoder | ||
| 11 | from qmk.constants import COL_LETTERS, ROW_LETTERS | ||
| 12 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 13 | from qmk.keyboard import keyboard_completer, keyboard_folder, render_layouts, render_layout, rules_mk | ||
| 14 | from qmk.info import info_json, keymap_json | ||
| 15 | from qmk.keymap import locate_keymap | ||
| 16 | from qmk.path import is_keyboard | ||
| 17 | |||
| 18 | UNICODE_SUPPORT = sys.stdout.encoding.lower().startswith('utf') | ||
| 19 | |||
| 20 | |||
| 21 | def _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 | |||
| 48 | def 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 | |||
| 72 | def 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 | |||
| 81 | def 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 | |||
| 105 | def 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 | |||
| 147 | def 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 | |||
| 166 | def 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 | |||
| 185 | def 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 | |||
| 203 | def 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 | ||
| 222 | def 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 | """ | ||
| 3 | from argcomplete.completers import FilesCompleter | ||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | import qmk.keymap | ||
| 7 | import qmk.path | ||
| 8 | from 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.') | ||
| 15 | def 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 | """ | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | |||
| 7 | from argcomplete.completers import FilesCompleter | ||
| 8 | from milc import cli | ||
| 9 | from kle2xy import KLE2xy | ||
| 10 | |||
| 11 | from qmk.converter import kle2qmk | ||
| 12 | from 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) | ||
| 18 | def 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 | ||
| 3 | import re | ||
| 4 | from milc import cli | ||
| 5 | from qmk.constants import LICENSE_TEXTS | ||
| 6 | from qmk.path import normpath | ||
| 7 | |||
| 8 | L_PAREN = re.compile(r'\(\[\{\<') | ||
| 9 | R_PAREN = re.compile(r'\)\]\}\>') | ||
| 10 | PUNCTUATION = re.compile(r'[\.,;:]+') | ||
| 11 | TRASH_PREFIX = re.compile(r'^(\s|/|\*|#)+') | ||
| 12 | TRASH_SUFFIX = re.compile(r'(\s|/|\*|#|\\)+$') | ||
| 13 | SPACE = re.compile(r'\s+') | ||
| 14 | SUFFIXES = ['.c', '.h', '.cpp', '.cxx', '.hpp', '.hxx'] | ||
| 15 | |||
| 16 | |||
| 17 | def _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 | |||
| 30 | def _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 | |||
| 37 | def _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 | |||
| 54 | def _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 | |||
| 68 | def _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) | ||
| 119 | def 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 | """ | ||
| 3 | from dotty_dict import dotty | ||
| 4 | from pathlib import Path | ||
| 5 | |||
| 6 | from milc import cli | ||
| 7 | |||
| 8 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 9 | from qmk.info import info_json | ||
| 10 | from qmk.keyboard import keyboard_completer, keyboard_folder_or_all, is_all_keyboards, list_keyboards | ||
| 11 | from qmk.keymap import locate_keymap, list_keymaps | ||
| 12 | from qmk.path import keyboard | ||
| 13 | from qmk.git import git_get_ignored_files | ||
| 14 | from qmk.c_parse import c_source_files, preprocess_c_file | ||
| 15 | from qmk.json_schema import json_load | ||
| 16 | |||
| 17 | CHIBIOS_CONF_CHECKS = ['chconf.h', 'halconf.h', 'mcuconf.h', 'board.h'] | ||
| 18 | INVALID_KB_FEATURES = set(['encoder_map', 'dip_switch_map', 'combo', 'tap_dance', 'via']) | ||
| 19 | INVALID_KM_NAMES = ['via', 'vial'] | ||
| 20 | |||
| 21 | |||
| 22 | def _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 | |||
| 38 | def _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 | |||
| 60 | def _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 | |||
| 82 | def _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 | |||
| 105 | def _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 | |||
| 124 | def _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 | |||
| 133 | def _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 | |||
| 142 | def _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 | |||
| 150 | def _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 | |||
| 165 | def _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 | |||
| 176 | def _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 | |||
| 184 | def _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 | |||
| 193 | def _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 | |||
| 219 | def _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 | |||
| 245 | def 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 | |||
| 281 | def 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 | ||
| 354 | def 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 | """ | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | import qmk.keyboard | ||
| 6 | |||
| 7 | |||
| 8 | @cli.subcommand("List the keyboards currently defined within QMK") | ||
| 9 | def 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 | """ | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | import qmk.keymap | ||
| 6 | from qmk.decorators import automagic_keyboard | ||
| 7 | from 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 | ||
| 13 | def 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 | """ | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | from qmk.decorators import automagic_keyboard | ||
| 6 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 7 | from 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 | ||
| 13 | def 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 | |||
| 3 | This will compile everything in parallel, for testing purposes. | ||
| 4 | """ | ||
| 5 | import os | ||
| 6 | from typing import List | ||
| 7 | from pathlib import Path | ||
| 8 | from subprocess import DEVNULL | ||
| 9 | from milc import cli | ||
| 10 | import shlex | ||
| 11 | |||
| 12 | from qmk.constants import QMK_FIRMWARE | ||
| 13 | from qmk.commands import find_make, get_make_parallel_args, build_environment | ||
| 14 | from qmk.search import search_keymap_targets, search_make_targets | ||
| 15 | from qmk.build_targets import BuildTarget, JsonKeymapBuildTarget | ||
| 16 | from qmk.util import maybe_exit_config | ||
| 17 | |||
| 18 | |||
| 19 | def 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. | ||
| 45 | all: print_failures | ||
| 46 | .PHONY: all_targets print_failures | ||
| 47 | print_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 | ||
| 85 | all_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) | ||
| 138 | def 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 | """ | ||
| 3 | import json | ||
| 4 | from pathlib import Path | ||
| 5 | from dotty_dict import dotty | ||
| 6 | |||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 10 | from qmk.info import info_json, find_info_json | ||
| 11 | from qmk.json_encoders import InfoJSONEncoder | ||
| 12 | from qmk.json_schema import json_load | ||
| 13 | |||
| 14 | |||
| 15 | def _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) | ||
| 31 | def 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 | """ | ||
| 3 | import re | ||
| 4 | import json | ||
| 5 | import shutil | ||
| 6 | from datetime import date | ||
| 7 | from pathlib import Path | ||
| 8 | from dotty_dict import dotty | ||
| 9 | |||
| 10 | from milc import cli | ||
| 11 | from milc.questions import choice, question, yesno | ||
| 12 | |||
| 13 | from qmk.git import git_get_username | ||
| 14 | from qmk.json_schema import load_jsonschema | ||
| 15 | from qmk.path import keyboard | ||
| 16 | from qmk.json_encoders import InfoJSONEncoder | ||
| 17 | from qmk.json_schema import deep_update | ||
| 18 | from qmk.constants import MCU2BOOTLOADER, QMK_FIRMWARE | ||
| 19 | |||
| 20 | COMMUNITY = Path('layouts/default/') | ||
| 21 | TEMPLATE = Path('data/templates/keyboard/') | ||
| 22 | |||
| 23 | # defaults | ||
| 24 | schema = dotty(load_jsonschema('keyboard')) | ||
| 25 | mcu_types = sorted(schema["properties.processor.enum"], key=str.casefold) | ||
| 26 | dev_boards = sorted(schema["properties.development_board.enum"], key=str.casefold) | ||
| 27 | available_layouts = sorted([x.name for x in COMMUNITY.iterdir() if x.is_dir()]) | ||
| 28 | |||
| 29 | |||
| 30 | def 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 | |||
| 38 | def layout_type(layout): | ||
| 39 | """Callable for argparse validation. | ||
| 40 | """ | ||
| 41 | if layout not in available_layouts: | ||
| 42 | raise ValueError | ||
| 43 | return layout | ||
| 44 | |||
| 45 | |||
| 46 | def keyboard_name(name): | ||
| 47 | """Callable for argparse validation. | ||
| 48 | """ | ||
| 49 | if not validate_keyboard_name(name): | ||
| 50 | raise ValueError | ||
| 51 | return name | ||
| 52 | |||
| 53 | |||
| 54 | def 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 | |||
| 61 | def select_default_bootloader(mcu): | ||
| 62 | """Provide sane defaults for bootloader | ||
| 63 | """ | ||
| 64 | return MCU2BOOTLOADER.get(mcu, "custom") | ||
| 65 | |||
| 66 | |||
| 67 | def 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 | |||
| 77 | def replace_string(src, token, value): | ||
| 78 | src.write_text(src.read_text().replace(token, value)) | ||
| 79 | |||
| 80 | |||
| 81 | def 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 | |||
| 113 | def _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 | |||
| 134 | def prompt_heading_subheading(heading, subheading): | ||
| 135 | cli.log.info(f"{{fg_yellow}}{heading}{{style_reset_all}}") | ||
| 136 | cli.log.info(subheading) | ||
| 137 | |||
| 138 | |||
| 139 | def prompt_keyboard(): | ||
| 140 | prompt_heading_subheading("Name Your Keyboard Project", """For more information, see: | ||
| 141 | https://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 | |||
| 148 | def 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 | |||
| 154 | def 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 | |||
| 160 | def prompt_layout(): | ||
| 161 | prompt_heading_subheading("Pick Base Layout", """As a starting point, one of the common layouts can be used to | ||
| 162 | bootstrap 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 | |||
| 171 | def 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, | ||
| 174 | or is the microcontroller integrated onto the PCB? | ||
| 175 | |||
| 176 | For more information, see: | ||
| 177 | https://docs.qmk.fm/compatible_microcontrollers""" | ||
| 178 | ) | ||
| 179 | |||
| 180 | return yesno("Using a Development Board?") | ||
| 181 | |||
| 182 | |||
| 183 | def prompt_dev_board(): | ||
| 184 | prompt_heading_subheading("Select Development Board", """For more information, see: | ||
| 185 | https://docs.qmk.fm/compatible_microcontrollers""") | ||
| 186 | |||
| 187 | return choice("Development Board?", dev_boards, default=dev_boards.index("promicro")) | ||
| 188 | |||
| 189 | |||
| 190 | def prompt_mcu(): | ||
| 191 | prompt_heading_subheading("Select Microcontroller", """For more information, see: | ||
| 192 | https://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') | ||
| 206 | def 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 | """ | ||
| 3 | import re | ||
| 4 | import json | ||
| 5 | import shutil | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | from milc import cli | ||
| 9 | from milc.questions import question, choice | ||
| 10 | |||
| 11 | from qmk.constants import HAS_QMK_USERSPACE, QMK_USERSPACE | ||
| 12 | from qmk.errors import NoSuchKeyboardError | ||
| 13 | from qmk.path import is_keyboard, keymaps, keymap | ||
| 14 | from qmk.git import git_get_username | ||
| 15 | from qmk.decorators import automagic_keyboard, automagic_keymap | ||
| 16 | from qmk.keyboard import keyboard_completer, keyboard_folder | ||
| 17 | from qmk.userspace import UserspaceDefs | ||
| 18 | from qmk.json_schema import json_load | ||
| 19 | from qmk.json_encoders import KeymapJSONEncoder | ||
| 20 | from qmk.info import info_json | ||
| 21 | |||
| 22 | |||
| 23 | def _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 | |||
| 37 | def _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 | |||
| 48 | def 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 | |||
| 55 | def prompt_keyboard(): | ||
| 56 | prompt = """{fg_yellow}Select Keyboard{style_reset_all} | ||
| 57 | If you're unsure you can view a full list of supported keyboards with {fg_yellow}qmk list-keyboards{style_reset_all}. | ||
| 58 | |||
| 59 | Keyboard Name? """ | ||
| 60 | return question(prompt) | ||
| 61 | |||
| 62 | |||
| 63 | def prompt_user(): | ||
| 64 | prompt = """ | ||
| 65 | {fg_yellow}Name Your Keymap{style_reset_all} | ||
| 66 | |||
| 67 | Keymap name? """ | ||
| 68 | return question(prompt, default=git_get_username()) | ||
| 69 | |||
| 70 | |||
| 71 | def prompt_converter(kb_name): | ||
| 72 | prompt = """ | ||
| 73 | {fg_yellow}Configure Development Board{style_reset_all} | ||
| 74 | For more information, see: | ||
| 75 | https://docs.qmk.fm/feature_converters | ||
| 76 | |||
| 77 | Use 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 | ||
| 96 | def 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 @@ | |||
| 1 | from . import convert_graphics | ||
| 2 | from . 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 | """ | ||
| 3 | from io import BytesIO | ||
| 4 | from qmk.path import normpath | ||
| 5 | from qmk.painter import generate_subs, render_header, render_source, valid_formats | ||
| 6 | from milc import cli | ||
| 7 | from 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') | ||
| 18 | def 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 | |||
| 4 | from io import BytesIO | ||
| 5 | from qmk.path import normpath | ||
| 6 | from qmk.painter_qff import _generate_font_glyphs_list, QFFFont | ||
| 7 | from qmk.painter import generate_subs, render_header, render_source, valid_formats | ||
| 8 | from 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') | ||
| 18 | def 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') | ||
| 36 | def 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 | |||
| 3 | QMK script to run unit and integration tests against our python code. | ||
| 4 | """ | ||
| 5 | from subprocess import DEVNULL | ||
| 6 | |||
| 7 | from 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) | ||
| 12 | def 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 @@ | |||
| 1 | from qmk.keyboard import keyboard_folder | ||
| 2 | |||
| 3 | from 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') | ||
| 9 | def 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 @@ | |||
| 1 | import fnmatch | ||
| 2 | import re | ||
| 3 | from subprocess import DEVNULL | ||
| 4 | |||
| 5 | from milc import cli | ||
| 6 | |||
| 7 | from 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) | ||
| 16 | def 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 @@ | |||
| 1 | from . import doctor | ||
| 2 | from . import add | ||
| 3 | from . import remove | ||
| 4 | from . import list | ||
| 5 | from . 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 | ||
| 3 | from pathlib import Path | ||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | from qmk.commands import parse_env_vars | ||
| 7 | from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 8 | from qmk.keyboard import keyboard_completer, keyboard_folder_or_all | ||
| 9 | from qmk.keymap import keymap_completer, is_keymap_target | ||
| 10 | from 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`.') | ||
| 18 | def 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 | ||
| 3 | from pathlib import Path | ||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 7 | from qmk.commands import build_environment | ||
| 8 | from qmk.userspace import UserspaceDefs | ||
| 9 | from qmk.build_targets import JsonKeymapBuildTarget | ||
| 10 | from qmk.search import search_keymap_targets | ||
| 11 | from qmk.cli.mass_compile import mass_compile_targets | ||
| 12 | from qmk.util import maybe_exit_config | ||
| 13 | |||
| 14 | |||
| 15 | def _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`.') | ||
| 26 | def 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 | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | from qmk.constants import QMK_FIRMWARE, HAS_QMK_USERSPACE | ||
| 6 | from qmk.cli.doctor.main import userspace_tests | ||
| 7 | |||
| 8 | |||
| 9 | @cli.subcommand('Checks userspace configuration.') | ||
| 10 | def 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 | ||
| 3 | from pathlib import Path | ||
| 4 | from dotty_dict import Dotty | ||
| 5 | from milc import cli | ||
| 6 | |||
| 7 | from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 8 | from qmk.userspace import UserspaceDefs | ||
| 9 | from qmk.build_targets import BuildTarget | ||
| 10 | from qmk.keyboard import is_all_keyboards, keyboard_folder | ||
| 11 | from qmk.keymap import is_keymap_target | ||
| 12 | from qmk.search import search_keymap_targets | ||
| 13 | from qmk.util import maybe_exit_config | ||
| 14 | |||
| 15 | |||
| 16 | def _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`.') | ||
| 22 | def 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 @@ | |||
| 1 | from milc import cli | ||
| 2 | from qmk.constants import QMK_USERSPACE | ||
| 3 | |||
| 4 | |||
| 5 | @cli.subcommand('Detected path to QMK Userspace.', hidden=True) | ||
| 6 | def 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 | ||
| 3 | from pathlib import Path | ||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | from qmk.commands import parse_env_vars | ||
| 7 | from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 8 | from qmk.keyboard import keyboard_completer, keyboard_folder_or_all | ||
| 9 | from qmk.keymap import keymap_completer | ||
| 10 | from 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`.') | ||
| 18 | def 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 | """ | ||
| 3 | import json | ||
| 4 | import re | ||
| 5 | |||
| 6 | from milc import cli | ||
| 7 | |||
| 8 | import qmk.keyboard | ||
| 9 | import qmk.path | ||
| 10 | from qmk.info import info_json | ||
| 11 | from qmk.json_encoders import KeymapJSONEncoder | ||
| 12 | from qmk.commands import dump_lines | ||
| 13 | from qmk.keymap import generate_json | ||
| 14 | |||
| 15 | |||
| 16 | def _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 | |||
| 23 | def _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 | |||
| 68 | def _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 | |||
| 79 | def _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.') | ||
| 124 | def 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 | """ | ||
| 3 | import os | ||
| 4 | import sys | ||
| 5 | import shutil | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | from milc import cli | ||
| 9 | import jsonschema | ||
| 10 | |||
| 11 | from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 12 | from qmk.json_schema import json_load, validate | ||
| 13 | from qmk.keyboard import keyboard_alias_definitions | ||
| 14 | from qmk.util import maybe_exit | ||
| 15 | from qmk.path import unix_style_path | ||
| 16 | |||
| 17 | |||
| 18 | def 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 | |||
| 29 | def 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 | |||
| 47 | def 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 | |||
| 72 | def 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 | |||
| 85 | def 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 | |||
| 94 | def 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 | |||
| 102 | def 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 | |||
| 3 | Gratefully adapted from https://stackoverflow.com/a/241506 | ||
| 4 | """ | ||
| 5 | import re | ||
| 6 | |||
| 7 | comment_pattern = re.compile(r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"', re.DOTALL | re.MULTILINE) | ||
| 8 | |||
| 9 | |||
| 10 | def _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 | |||
| 17 | def 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 @@ | |||
| 1 | import os | ||
| 2 | |||
| 3 | from pathlib import Path | ||
| 4 | from functools import lru_cache | ||
| 5 | |||
| 6 | from milc.attrdict import AttrDict | ||
| 7 | |||
| 8 | from qmk.json_schema import json_load, validate, merge_ordered_dicts | ||
| 9 | from qmk.util import truthy | ||
| 10 | from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 11 | from qmk.path import under_qmk_firmware, under_qmk_userspace | ||
| 12 | |||
| 13 | COMMUNITY_MODULE_JSON_FILENAME = 'qmk_module.json' | ||
| 14 | |||
| 15 | |||
| 16 | class 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) | ||
| 24 | def 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 | |||
| 45 | def 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 | |||
| 60 | def 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 | |||
| 79 | def 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 | |||
| 97 | def 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 | |||
| 4 | import json | ||
| 5 | import os | ||
| 6 | import re | ||
| 7 | import shlex | ||
| 8 | import shutil | ||
| 9 | from functools import lru_cache | ||
| 10 | from pathlib import Path | ||
| 11 | from typing import Dict, Iterator, List | ||
| 12 | |||
| 13 | from milc import cli | ||
| 14 | |||
| 15 | from qmk.commands import find_make | ||
| 16 | from qmk.constants import QMK_FIRMWARE | ||
| 17 | |||
| 18 | |||
| 19 | @lru_cache(maxsize=10) | ||
| 20 | def 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) | ||
| 39 | def 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 | |||
| 65 | file_re = re.compile(r'printf "Compiling: ([^"]+)') | ||
| 66 | cmd_re = re.compile(r'LOG=\$\((.+?)&&') | ||
| 67 | |||
| 68 | |||
| 69 | def 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 | |||
| 105 | def 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 | """ | ||
| 3 | from os import environ | ||
| 4 | from datetime import date | ||
| 5 | from pathlib import Path | ||
| 6 | |||
| 7 | from qmk.userspace import detect_qmk_userspace | ||
| 8 | |||
| 9 | # The root of the qmk_firmware tree. | ||
| 10 | QMK_FIRMWARE = Path.cwd() | ||
| 11 | |||
| 12 | # The detected userspace tree | ||
| 13 | QMK_USERSPACE = detect_qmk_userspace() | ||
| 14 | |||
| 15 | # Whether or not we have a separate userspace directory | ||
| 16 | HAS_QMK_USERSPACE = True if QMK_USERSPACE is not None else False | ||
| 17 | |||
| 18 | # Upstream repo url | ||
| 19 | QMK_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. | ||
| 22 | MAX_KEYBOARD_SUBFOLDERS = 5 | ||
| 23 | |||
| 24 | # Supported processor types | ||
| 25 | CHIBIOS_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' | ||
| 26 | LUFA_PROCESSORS = 'at90usb162', 'atmega16u2', 'atmega32u2', 'atmega16u4', 'atmega32u4', 'at90usb646', 'at90usb647', 'at90usb1286', 'at90usb1287', None | ||
| 27 | VUSB_PROCESSORS = 'atmega32a', 'atmega328p', 'atmega328', 'attiny85' | ||
| 28 | |||
| 29 | # Bootloaders of the supported processors | ||
| 30 | MCU2BOOTLOADER = { | ||
| 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 | ||
| 75 | LEGACY_KEYCODES = { # Comment here is to force multiline formatting | ||
| 76 | 'RESET': 'QK_BOOT' | ||
| 77 | } | ||
| 78 | |||
| 79 | # Map VID:PID values to bootloaders | ||
| 80 | BOOTLOADER_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 | ||
| 130 | DATE_FORMAT = '%Y-%m-%d' | ||
| 131 | DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S %Z' | ||
| 132 | TIME_FORMAT = '%H:%M:%S' | ||
| 133 | |||
| 134 | # Used when generating matrix locations | ||
| 135 | COL_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijilmnopqrstuvwxyz' | ||
| 136 | ROW_LETTERS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop' | ||
| 137 | |||
| 138 | # Constants that should match their counterparts in make | ||
| 139 | BUILD_DIR = environ.get('BUILD_DIR', '.build') | ||
| 140 | INTERMEDIATE_OUTPUT_PREFIX = f'{BUILD_DIR}/obj_' | ||
| 141 | |||
| 142 | # Headers for generated files | ||
| 143 | GPL2_HEADER_C_LIKE = f'''\ | ||
| 144 | // Copyright {date.today().year} QMK | ||
| 145 | // SPDX-License-Identifier: GPL-2.0-or-later | ||
| 146 | ''' | ||
| 147 | |||
| 148 | GPL2_HEADER_SH_LIKE = f'''\ | ||
| 149 | # Copyright {date.today().year} QMK | ||
| 150 | # SPDX-License-Identifier: GPL-2.0-or-later | ||
| 151 | ''' | ||
| 152 | |||
| 153 | GENERATED_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 | |||
| 177 | GENERATED_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 | |||
| 204 | LICENSE_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 | |||
| 327 | JOYSTICK_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 | """ | ||
| 3 | from collections import OrderedDict | ||
| 4 | |||
| 5 | |||
| 6 | def 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 | |||
| 3 | The 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 | """ | ||
| 5 | from time import gmtime, strftime | ||
| 6 | |||
| 7 | from qmk.constants import DATE_FORMAT, DATETIME_FORMAT, TIME_FORMAT | ||
| 8 | from qmk.decorators import lru_cache | ||
| 9 | |||
| 10 | |||
| 11 | @lru_cache(timeout=5) | ||
| 12 | def 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) | ||
| 19 | def 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) | ||
| 26 | def 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 | """ | ||
| 3 | import functools | ||
| 4 | from time import monotonic | ||
| 5 | |||
| 6 | from milc import cli | ||
| 7 | |||
| 8 | from qmk.keyboard import find_keyboard_from_dir, keyboard_folder | ||
| 9 | from qmk.keymap import find_keymap_from_dir | ||
| 10 | |||
| 11 | |||
| 12 | def _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 | |||
| 21 | def 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 | |||
| 48 | def 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 | |||
| 70 | def 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 | """ | ||
| 3 | import shutil | ||
| 4 | from pathlib import Path | ||
| 5 | from subprocess import DEVNULL | ||
| 6 | from os import chdir, environ, makedirs, pathsep | ||
| 7 | from milc import cli | ||
| 8 | |||
| 9 | from qmk.constants import QMK_FIRMWARE | ||
| 10 | |||
| 11 | DOCS_PATH = QMK_FIRMWARE / 'docs' | ||
| 12 | BUILDDEFS_PATH = QMK_FIRMWARE / 'builddefs' / 'docsgen' | ||
| 13 | BUILD_PATH = QMK_FIRMWARE / '.build' | ||
| 14 | CACHE_PATH = BUILD_PATH / 'cache' | ||
| 15 | NODE_MODULES_PATH = BUILD_PATH / 'node_modules' | ||
| 16 | BUILD_DOCS_PATH = BUILD_PATH / 'docs' | ||
| 17 | DOXYGEN_PATH = BUILD_DOCS_PATH / 'static' / 'doxygen' | ||
| 18 | |||
| 19 | |||
| 20 | def 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 | |||
| 37 | def 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 @@ | |||
| 1 | class 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 | |||
| 8 | class 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 @@ | |||
| 1 | import platform | ||
| 2 | import shutil | ||
| 3 | import time | ||
| 4 | import os | ||
| 5 | import signal | ||
| 6 | |||
| 7 | import usb.core | ||
| 8 | |||
| 9 | from qmk.constants import BOOTLOADER_VIDS_PIDS | ||
| 10 | from 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 | |||
| 23 | AVRDUDE_MCU = { | ||
| 24 | 'atmega32a': 'm32', | ||
| 25 | 'atmega328p': 'm328p', | ||
| 26 | 'atmega328': 'm328', | ||
| 27 | } | ||
| 28 | # yapf: enable | ||
| 29 | |||
| 30 | |||
| 31 | class 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 | ||
| 48 | def _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 | |||
| 60 | def _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 | |||
| 74 | def _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 | |||
| 80 | def _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 | |||
| 110 | def _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 | |||
| 139 | def _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 | |||
| 148 | def _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 | |||
| 155 | def _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 | |||
| 173 | def _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 | |||
| 185 | def _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 | |||
| 194 | def _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 | |||
| 201 | def _flash_mdloader(file): | ||
| 202 | cli.run(['mdloader', '--first', '--download', file, '--restart'], capture_output=False) | ||
| 203 | |||
| 204 | |||
| 205 | def _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 | |||
| 213 | def 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 | """ | ||
| 3 | from subprocess import DEVNULL | ||
| 4 | from pathlib import Path | ||
| 5 | |||
| 6 | from milc import cli | ||
| 7 | |||
| 8 | from qmk.constants import QMK_FIRMWARE | ||
| 9 | |||
| 10 | |||
| 11 | def 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 | |||
| 36 | def 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 | |||
| 45 | def 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 | |||
| 57 | def 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 | |||
| 65 | def 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 | |||
| 74 | def 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 | |||
| 84 | def 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 | |||
| 101 | def 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 | |||
| 113 | def 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 | |||
| 124 | def 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 | |||
| 132 | def 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 | |||
| 141 | def 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 @@ | |||
| 1 | from dotty_dict import dotty | ||
| 2 | from datetime import date | ||
| 3 | from pathlib import Path | ||
| 4 | import json | ||
| 5 | |||
| 6 | from qmk.git import git_get_username | ||
| 7 | from qmk.json_schema import validate | ||
| 8 | from qmk.path import keyboard, keymaps | ||
| 9 | from qmk.constants import MCU2BOOTLOADER, LEGACY_KEYCODES | ||
| 10 | from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder | ||
| 11 | from qmk.json_schema import deep_update, json_load | ||
| 12 | |||
| 13 | TEMPLATE = Path('data/templates/keyboard/') | ||
| 14 | |||
| 15 | |||
| 16 | def 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 | |||
| 26 | def _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 | |||
| 40 | def _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 | |||
| 57 | def _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 | |||
| 80 | def 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 | |||
| 99 | def 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 | |||
| 148 | def 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 | """ | ||
| 3 | import re | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import jsonschema | ||
| 7 | from dotty_dict import dotty | ||
| 8 | from enum import IntFlag | ||
| 9 | |||
| 10 | from milc import cli | ||
| 11 | |||
| 12 | from qmk.constants import COL_LETTERS, ROW_LETTERS, CHIBIOS_PROCESSORS, LUFA_PROCESSORS, VUSB_PROCESSORS, JOYSTICK_AXES | ||
| 13 | from qmk.c_parse import find_layouts, parse_config_h_file, find_led_config | ||
| 14 | from qmk.json_schema import deep_update, json_load, validate | ||
| 15 | from qmk.keyboard import config_h, rules_mk | ||
| 16 | from qmk.commands import parse_configurator_json | ||
| 17 | from qmk.makefile import parse_rules_mk_file | ||
| 18 | from qmk.math_ops import compute | ||
| 19 | from qmk.util import maybe_exit, truthy | ||
| 20 | |||
| 21 | true_values = ['1', 'on', 'yes'] | ||
| 22 | false_values = ['0', 'off', 'no'] | ||
| 23 | |||
| 24 | |||
| 25 | class LedFlags(IntFlag): | ||
| 26 | ALL = 0xFF | ||
| 27 | NONE = 0x00 | ||
| 28 | MODIFIER = 0x01 | ||
| 29 | UNDERGLOW = 0x02 | ||
| 30 | KEYLIGHT = 0x04 | ||
| 31 | INDICATOR = 0x08 | ||
| 32 | |||
| 33 | |||
| 34 | def _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 | |||
| 59 | def _valid_community_layout(layout): | ||
| 60 | """Validate that a declared community list exists | ||
| 61 | """ | ||
| 62 | return (Path('layouts/default') / layout).exists() | ||
| 63 | |||
| 64 | |||
| 65 | def _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 | |||
| 70 | def _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 | |||
| 98 | def _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 | |||
| 124 | def _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 | |||
| 195 | def _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 | |||
| 205 | def _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 | |||
| 214 | def _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 | |||
| 233 | def 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 | |||
| 288 | def _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 | |||
| 315 | def _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 | |||
| 333 | def _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 | |||
| 350 | def _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 | |||
| 356 | def _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 | |||
| 379 | def _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 | |||
| 387 | def _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 | |||
| 400 | def _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 | |||
| 413 | def _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 | |||
| 439 | def _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 | |||
| 459 | def _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 | |||
| 479 | def _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 | |||
| 494 | def _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 | |||
| 502 | def _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 | |||
| 513 | def _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 | |||
| 548 | def _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 | |||
| 577 | def _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 | |||
| 615 | def _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 | |||
| 661 | def _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 | |||
| 710 | def _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 | |||
| 730 | def _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 | |||
| 784 | def 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 | |||
| 800 | def _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 | |||
| 847 | def _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 | |||
| 868 | def _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 | |||
| 878 | def _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 | |||
| 897 | def _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 | |||
| 924 | def _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 | |||
| 945 | def _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 | |||
| 952 | def _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 | |||
| 959 | def 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 | |||
| 974 | def 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 | |||
| 988 | def 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 | |||
| 1000 | def 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 | |||
| 1057 | def 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 | |||
| 1078 | def 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 | |||
| 1090 | def 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 | |||
| 1117 | def 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 | """ | ||
| 3 | import json | ||
| 4 | from decimal import Decimal | ||
| 5 | |||
| 6 | newline = '\n' | ||
| 7 | |||
| 8 | |||
| 9 | class 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 | |||
| 97 | class 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 | |||
| 158 | class 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 | |||
| 222 | class 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 | |||
| 240 | class 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 | """ | ||
| 3 | import json | ||
| 4 | import hjson | ||
| 5 | import jsonschema | ||
| 6 | from collections.abc import Mapping | ||
| 7 | from functools import lru_cache | ||
| 8 | from typing import OrderedDict | ||
| 9 | from pathlib import Path | ||
| 10 | from copy import deepcopy | ||
| 11 | |||
| 12 | from milc import cli | ||
| 13 | |||
| 14 | from qmk.util import maybe_exit | ||
| 15 | |||
| 16 | |||
| 17 | def _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) | ||
| 29 | def _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 | |||
| 49 | def 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) | ||
| 54 | def 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) | ||
| 69 | def 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) | ||
| 90 | def 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 | |||
| 99 | def validate(data, schema): | ||
| 100 | """Validates data against a schema. | ||
| 101 | """ | ||
| 102 | validator = create_validator(schema) | ||
| 103 | |||
| 104 | return validator(data) | ||
| 105 | |||
| 106 | |||
| 107 | def 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 | |||
| 120 | def 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 | """ | ||
| 3 | from array import array | ||
| 4 | from functools import lru_cache | ||
| 5 | from math import ceil | ||
| 6 | from pathlib import Path | ||
| 7 | import os | ||
| 8 | from glob import glob | ||
| 9 | |||
| 10 | import qmk.path | ||
| 11 | from qmk.c_parse import parse_config_h_file | ||
| 12 | from qmk.json_schema import json_load | ||
| 13 | from qmk.makefile import parse_rules_mk_file | ||
| 14 | |||
| 15 | BOX_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 | } | ||
| 33 | ENC_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 | |||
| 57 | class 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 | |||
| 70 | base_path = os.path.join(os.getcwd(), "keyboards") + os.path.sep | ||
| 71 | |||
| 72 | |||
| 73 | @lru_cache(maxsize=1) | ||
| 74 | def keyboard_alias_definitions(): | ||
| 75 | return json_load(Path('data/mappings/keyboard_aliases.hjson')) | ||
| 76 | |||
| 77 | |||
| 78 | def 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 | |||
| 86 | def 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 | |||
| 106 | def 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 | |||
| 119 | def 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 | |||
| 138 | def 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 | |||
| 154 | def 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 | |||
| 166 | def _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 | |||
| 172 | def 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) | ||
| 179 | def 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 | |||
| 194 | def 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 | |||
| 214 | def 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 | |||
| 234 | def 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 | |||
| 270 | def 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 | |||
| 282 | def 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 | |||
| 311 | def 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 | |||
| 343 | def 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 | |||
| 375 | def 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 @@ | |||
| 1 | from pathlib import Path | ||
| 2 | |||
| 3 | from qmk.json_schema import merge_ordered_dicts, deep_update, json_load, validate | ||
| 4 | |||
| 5 | CONSTANTS_PATH = Path('data/constants/') | ||
| 6 | KEYCODES_PATH = CONSTANTS_PATH / 'keycodes' | ||
| 7 | EXTRAS_PATH = KEYCODES_PATH / 'extras' | ||
| 8 | |||
| 9 | |||
| 10 | def _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 | |||
| 19 | def _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 | |||
| 28 | def _search_path(lang=None): | ||
| 29 | return EXTRAS_PATH if lang else KEYCODES_PATH | ||
| 30 | |||
| 31 | |||
| 32 | def _search_prefix(lang=None): | ||
| 33 | return f'keycodes_{lang}' if lang else 'keycodes' | ||
| 34 | |||
| 35 | |||
| 36 | def _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 | |||
| 51 | def _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 | |||
| 64 | def _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 | |||
| 78 | def 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 | |||
| 102 | def 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 | |||
| 111 | def 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 | """ | ||
| 3 | import json | ||
| 4 | import sys | ||
| 5 | from pathlib import Path | ||
| 6 | from subprocess import DEVNULL | ||
| 7 | |||
| 8 | import argcomplete | ||
| 9 | from milc import cli | ||
| 10 | from pygments.lexers.c_cpp import CLexer | ||
| 11 | from pygments.token import Token | ||
| 12 | from pygments import lex | ||
| 13 | |||
| 14 | import qmk.path | ||
| 15 | from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 16 | from qmk.keyboard import find_keyboard_from_dir, keyboard_folder, keyboard_aliases | ||
| 17 | from qmk.errors import CppError | ||
| 18 | from qmk.info import info_json | ||
| 19 | |||
| 20 | # The `keymap.c` template to use when a keyboard doesn't have its own | ||
| 21 | DEFAULT_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 | |||
| 44 | def _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 | |||
| 56 | def _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 | |||
| 70 | def _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 | |||
| 83 | def _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 | |||
| 144 | def _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 | |||
| 153 | def 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 | |||
| 190 | def 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 | |||
| 209 | def 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 | |||
| 243 | def 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 | |||
| 272 | def 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 | |||
| 323 | def 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 | |||
| 332 | def 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 | |||
| 357 | def 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 | |||
| 409 | def 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 | |||
| 419 | def 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 | |||
| 483 | def _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 | |||
| 502 | def _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 | |||
| 622 | def 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 | |||
| 651 | def 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 | """ | ||
| 3 | from pathlib import Path | ||
| 4 | |||
| 5 | |||
| 6 | def 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 | |||
| 3 | Gratefully copied from https://stackoverflow.com/a/9558001 | ||
| 4 | """ | ||
| 5 | import ast | ||
| 6 | import operator as op | ||
| 7 | |||
| 8 | # supported operators | ||
| 9 | operators = {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 | |||
| 12 | def 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 | |||
| 25 | def _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 | """ | ||
| 3 | import datetime | ||
| 4 | import math | ||
| 5 | import re | ||
| 6 | from pathlib import Path | ||
| 7 | from string import Template | ||
| 8 | from PIL import Image, ImageOps | ||
| 9 | |||
| 10 | # The list of valid formats Quantum Painter supports | ||
| 11 | valid_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 | |||
| 85 | def _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 | |||
| 90 | def _render_numeration(metadata): | ||
| 91 | return _render_text(range(len(metadata))) | ||
| 92 | |||
| 93 | |||
| 94 | def _render_values(metadata, key): | ||
| 95 | return _render_text([i[key] for i in metadata]) | ||
| 96 | |||
| 97 | |||
| 98 | def _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 | |||
| 141 | def 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 | |||
| 160 | def 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 | |||
| 205 | license_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 | |||
| 214 | def render_license(subs): | ||
| 215 | license_txt = Template(license_template) | ||
| 216 | return license_txt.substitute(subs) | ||
| 217 | |||
| 218 | |||
| 219 | header_file_template = """\ | ||
| 220 | ${license} | ||
| 221 | #pragma once | ||
| 222 | |||
| 223 | #include <qp.h> | ||
| 224 | |||
| 225 | extern const uint32_t ${var_prefix}_${sane_name}_length; | ||
| 226 | extern const uint8_t ${var_prefix}_${sane_name}[${byte_count}]; | ||
| 227 | """ | ||
| 228 | |||
| 229 | |||
| 230 | def render_header(subs): | ||
| 231 | header_txt = Template(header_file_template) | ||
| 232 | return header_txt.substitute(subs) | ||
| 233 | |||
| 234 | |||
| 235 | source_file_template = """\ | ||
| 236 | ${license} | ||
| 237 | ${metadata} | ||
| 238 | |||
| 239 | #include <qp.h> | ||
| 240 | |||
| 241 | const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count}; | ||
| 242 | |||
| 243 | // clang-format off | ||
| 244 | const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = { | ||
| 245 | ${bytes_lines} | ||
| 246 | }; | ||
| 247 | // clang-format on | ||
| 248 | """ | ||
| 249 | |||
| 250 | |||
| 251 | def render_source(subs): | ||
| 252 | source_txt = Template(source_file_template) | ||
| 253 | return source_txt.substitute(subs) | ||
| 254 | |||
| 255 | |||
| 256 | def 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 | |||
| 267 | def 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 | |||
| 273 | def 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 | |||
| 279 | def 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 | |||
| 320 | def 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 | |||
| 326 | def 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 | |||
| 409 | def 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 | |||
| 7 | from pathlib import Path | ||
| 8 | from typing import Dict, Any | ||
| 9 | from colorsys import rgb_to_hsv | ||
| 10 | from PIL import Image, ImageDraw, ImageFont, ImageChops | ||
| 11 | from PIL._binary import o8, o16le as o16, o32le as o32 | ||
| 12 | from qmk.painter_qgf import QGFBlockHeader, QGFFramePaletteDescriptorV1 | ||
| 13 | from milc.attrdict import AttrDict | ||
| 14 | import qmk.painter | ||
| 15 | |||
| 16 | |||
| 17 | def o24(i): | ||
| 18 | return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) | ||
| 19 | |||
| 20 | |||
| 21 | ######################################################################################################################## | ||
| 22 | |||
| 23 | |||
| 24 | class 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 | |||
| 45 | class 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 | |||
| 96 | class 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 | |||
| 121 | class 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 | |||
| 146 | class 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 | |||
| 163 | def _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 | |||
| 180 | class 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 | |||
| 8 | import functools | ||
| 9 | from colorsys import rgb_to_hsv | ||
| 10 | from types import FunctionType | ||
| 11 | from PIL import Image, ImageFile, ImageChops | ||
| 12 | from PIL._binary import o8, o16le as o16, o32le as o32 | ||
| 13 | import qmk.painter | ||
| 14 | |||
| 15 | |||
| 16 | def 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 | ||
| 21 | def 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 | |||
| 29 | class 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 | |||
| 43 | class 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 | |||
| 83 | class 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 | |||
| 104 | class 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 | |||
| 154 | class 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 | |||
| 177 | class 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 | |||
| 211 | class 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 | |||
| 228 | class 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 | |||
| 240 | def _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 | |||
| 248 | def _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 | |||
| 263 | def _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 | ||
| 330 | def _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 | |||
| 396 | def _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 | ||
| 456 | Image.register_open(QGFImageFile.format, QGFImageFile, _accept) | ||
| 457 | Image.register_save(QGFImageFile.format, _save) | ||
| 458 | Image.register_save_all(QGFImageFile.format, _save) | ||
| 459 | Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}") | ||
| 460 | Image.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 | """ | ||
| 3 | import logging | ||
| 4 | import os | ||
| 5 | import argparse | ||
| 6 | from pathlib import Path, PureWindowsPath, PurePosixPath | ||
| 7 | |||
| 8 | from qmk.constants import MAX_KEYBOARD_SUBFOLDERS, QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE | ||
| 9 | from qmk.errors import NoSuchKeyboardError | ||
| 10 | |||
| 11 | |||
| 12 | def 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 | |||
| 29 | def 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 | |||
| 38 | def 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 | |||
| 49 | def 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 | |||
| 60 | def 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 | |||
| 72 | def 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 | |||
| 78 | def 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 | |||
| 119 | def 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 | |||
| 134 | def 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 | |||
| 147 | def 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 | |||
| 169 | class 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 | """ | ||
| 3 | from dataclasses import dataclass | ||
| 4 | import contextlib | ||
| 5 | import functools | ||
| 6 | import fnmatch | ||
| 7 | import json | ||
| 8 | import logging | ||
| 9 | import re | ||
| 10 | from typing import Callable, Dict, List, Optional, Tuple, Union | ||
| 11 | from dotty_dict import dotty, Dotty | ||
| 12 | from milc import cli | ||
| 13 | |||
| 14 | from qmk.util import parallel_map | ||
| 15 | from qmk.info import keymap_json | ||
| 16 | from qmk.keyboard import list_keyboards, keyboard_folder | ||
| 17 | from qmk.keymap import list_keymaps, locate_keymap | ||
| 18 | from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget | ||
| 19 | |||
| 20 | |||
| 21 | @dataclass | ||
| 22 | class 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> | ||
| 50 | class 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 | |||
| 73 | class 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 | |||
| 80 | class 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 | |||
| 87 | class 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 | |||
| 95 | class 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 | |||
| 103 | def _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 | |||
| 115 | def 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 | |||
| 120 | def _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 | ||
| 134 | def ignore_logging(): | ||
| 135 | old = _set_log_level(logging.CRITICAL) | ||
| 136 | yield | ||
| 137 | _set_log_level(old) | ||
| 138 | |||
| 139 | |||
| 140 | def _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 | |||
| 148 | def _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 | |||
| 155 | def _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 | |||
| 163 | def 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 | |||
| 183 | def _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 | |||
| 215 | def 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 | |||
| 225 | def _construct_build_target(e: KeyboardKeymapDesc): | ||
| 226 | return e.to_build_target() | ||
| 227 | |||
| 228 | |||
| 229 | def _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 | |||
| 309 | def 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 | |||
| 324 | def 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 | """ | ||
| 3 | from milc import cli | ||
| 4 | |||
| 5 | |||
| 6 | def 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 | |||
| 62 | def 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 | ||
| 2 | info.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 @@ | |||
| 1 | class 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 @@ | |||
| 1 | import platform | ||
| 2 | from subprocess import DEVNULL | ||
| 3 | |||
| 4 | from milc import cli | ||
| 5 | |||
| 6 | is_windows = 'windows' in platform.platform().lower() | ||
| 7 | |||
| 8 | |||
| 9 | def check_subcommand(command, *args): | ||
| 10 | cmd = ['qmk', command, *args] | ||
| 11 | result = cli.run(cmd, stdin=DEVNULL, combined_output=True) | ||
| 12 | return result | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 24 | def 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 | |||
| 34 | def test_format_c(): | ||
| 35 | result = check_subcommand('format-c', '-n', 'quantum/matrix.c') | ||
| 36 | check_returncode(result) | ||
| 37 | |||
| 38 | |||
| 39 | def test_format_c_all(): | ||
| 40 | result = check_subcommand('format-c', '-n', '-a') | ||
| 41 | check_returncode(result, [0, 1]) | ||
| 42 | |||
| 43 | |||
| 44 | def test_compile(): | ||
| 45 | result = check_subcommand('compile', '-kb', 'handwired/pytest/basic', '-km', 'default', '-n') | ||
| 46 | check_returncode(result) | ||
| 47 | |||
| 48 | |||
| 49 | def test_compile_json(): | ||
| 50 | result = check_subcommand('compile', '-kb', 'handwired/pytest/basic', '-km', 'default_json', '-n') | ||
| 51 | check_returncode(result) | ||
| 52 | |||
| 53 | |||
| 54 | def test_flash(): | ||
| 55 | result = check_subcommand('flash', '-kb', 'handwired/pytest/basic', '-km', 'default', '-n') | ||
| 56 | check_returncode(result) | ||
| 57 | |||
| 58 | |||
| 59 | def test_flash_bootloaders(): | ||
| 60 | result = check_subcommand('flash', '-b') | ||
| 61 | check_returncode(result, [1]) | ||
| 62 | |||
| 63 | |||
| 64 | def 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 | |||
| 70 | def 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 | |||
| 77 | def test_hello(): | ||
| 78 | result = check_subcommand('hello') | ||
| 79 | check_returncode(result) | ||
| 80 | assert 'Hello,' in result.stdout | ||
| 81 | |||
| 82 | |||
| 83 | def 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 | |||
| 89 | def 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 | |||
| 97 | def 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 | |||
| 104 | def 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 | |||
| 111 | def 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 | |||
| 117 | def 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 | |||
| 123 | def 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 | |||
| 129 | def 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 | |||
| 135 | def 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 | |||
| 141 | def 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 | |||
| 156 | const 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 | |||
| 171 | def 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 | |||
| 179 | def 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 | |||
| 194 | const 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 | |||
| 209 | def 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 | |||
| 215 | def 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 | |||
| 224 | def 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 | |||
| 237 | def 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 | |||
| 249 | def 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 | |||
| 264 | def 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 | |||
| 270 | def 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 | |||
| 276 | def 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 | |||
| 282 | def test_generate_api(): | ||
| 283 | result = check_subcommand('generate-api', '--dry-run', '--filter', 'handwired/pytest') | ||
| 284 | check_returncode(result) | ||
| 285 | |||
| 286 | |||
| 287 | def 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 | |||
| 294 | def 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 | |||
| 309 | def 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 | |||
| 316 | def 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 | |||
| 322 | def 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 | |||
| 328 | def 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 | |||
| 334 | def 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 | |||
| 340 | def 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 | |||
| 346 | def 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 | |||
| 356 | def 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 | |||
| 366 | def 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 | |||
| 375 | def 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 | |||
| 384 | def 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 @@ | |||
| 1 | from qmk.errors import NoSuchKeyboardError | ||
| 2 | |||
| 3 | |||
| 4 | def 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 @@ | |||
| 1 | import qmk.keymap | ||
| 2 | |||
| 3 | |||
| 4 | def 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 | |||
| 24 | const 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 | |||
| 37 | def 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 | |||
| 42 | def 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 @@ | |||
| 1 | import os | ||
| 2 | from pathlib import Path | ||
| 3 | |||
| 4 | import qmk.path | ||
| 5 | |||
| 6 | |||
| 7 | def 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 | |||
| 12 | def 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 | ||
| 3 | from os import environ | ||
| 4 | from pathlib import Path | ||
| 5 | import json | ||
| 6 | import jsonschema | ||
| 7 | |||
| 8 | from milc import cli | ||
| 9 | |||
| 10 | from qmk.json_schema import validate, json_load | ||
| 11 | from qmk.json_encoders import UserspaceJSONEncoder | ||
| 12 | |||
| 13 | |||
| 14 | def 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 | |||
| 41 | def 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 | |||
| 51 | def 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 | |||
| 65 | class 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 | |||
| 202 | class 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 | """ | ||
| 3 | import contextlib | ||
| 4 | import multiprocessing | ||
| 5 | import sys | ||
| 6 | import re | ||
| 7 | |||
| 8 | from milc import cli | ||
| 9 | |||
| 10 | TRIPLET_PATTERN = re.compile(r'^(\d+)\.(\d+)\.(\d+)') | ||
| 11 | |||
| 12 | maybe_exit_should_exit = True | ||
| 13 | maybe_exit_reraise = False | ||
| 14 | |||
| 15 | |||
| 16 | # Controls whether or not early `exit()` calls should be made | ||
| 17 | def 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 | |||
| 26 | def 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 | |||
| 33 | def 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 | ||
| 55 | def 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 | |||
| 93 | def 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 | |||
| 104 | def 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}' | ||
