at master 9.6 kB view raw
1"""This script automates the creation of new keyboard directories using a starter template. 2""" 3import re 4import json 5import shutil 6from datetime import date 7from pathlib import Path 8from dotty_dict import dotty 9 10from milc import cli 11from milc.questions import choice, question, yesno 12 13from qmk.git import git_get_username 14from qmk.json_schema import load_jsonschema 15from qmk.path import keyboard 16from qmk.json_encoders import InfoJSONEncoder 17from qmk.json_schema import deep_update 18from qmk.constants import MCU2BOOTLOADER, QMK_FIRMWARE 19 20COMMUNITY = Path('layouts/default/') 21TEMPLATE = Path('data/templates/keyboard/') 22 23# defaults 24schema = dotty(load_jsonschema('keyboard')) 25mcu_types = sorted(schema["properties.processor.enum"], key=str.casefold) 26dev_boards = sorted(schema["properties.development_board.enum"], key=str.casefold) 27available_layouts = sorted([x.name for x in COMMUNITY.iterdir() if x.is_dir()]) 28 29 30def mcu_type(mcu): 31 """Callable for argparse validation. 32 """ 33 if mcu not in (dev_boards + mcu_types): 34 raise ValueError 35 return mcu 36 37 38def layout_type(layout): 39 """Callable for argparse validation. 40 """ 41 if layout not in available_layouts: 42 raise ValueError 43 return layout 44 45 46def keyboard_name(name): 47 """Callable for argparse validation. 48 """ 49 if not validate_keyboard_name(name): 50 raise ValueError 51 return name 52 53 54def validate_keyboard_name(name): 55 """Returns True if the given keyboard name contains only lowercase a-z, 0-9 and underscore characters. 56 """ 57 regex = re.compile(r'^[a-z0-9][a-z0-9/_]+$') 58 return bool(regex.match(name)) 59 60 61def select_default_bootloader(mcu): 62 """Provide sane defaults for bootloader 63 """ 64 return MCU2BOOTLOADER.get(mcu, "custom") 65 66 67def replace_placeholders(src, dest, tokens): 68 """Replaces the given placeholders in each template file. 69 """ 70 content = src.read_text() 71 for key, value in tokens.items(): 72 content = content.replace(f'%{key}%', value) 73 74 dest.write_text(content) 75 76 77def replace_string(src, token, value): 78 src.write_text(src.read_text().replace(token, value)) 79 80 81def augment_community_info(config, src, dest): 82 """Splice in any additional data into info.json 83 """ 84 info = json.loads(src.read_text()) 85 template = json.loads(dest.read_text()) 86 87 # merge community with template 88 deep_update(info, template) 89 deep_update(info, config) 90 91 # avoid assumptions on macro name by using the first available 92 first_layout = next(iter(info["layouts"].values()))["layout"] 93 94 # guess at width and height now its optional 95 width, height = (0, 0) 96 for item in first_layout: 97 width = max(width, int(item["x"]) + 1) 98 height = max(height, int(item["y"]) + 1) 99 100 info["matrix_pins"] = { 101 "cols": ["C2"] * width, 102 "rows": ["D1"] * height, 103 } 104 105 # assume a 1:1 mapping on matrix to electrical 106 for item in first_layout: 107 item["matrix"] = [int(item["y"]), int(item["x"])] 108 109 # finally write out the updated json 110 dest.write_text(json.dumps(info, cls=InfoJSONEncoder, sort_keys=True)) 111 112 113def _question(*args, **kwargs): 114 """Ugly workaround until 'milc' learns to display a repromt msg 115 """ 116 # TODO: Remove this once milc.questions.question handles reprompt messages 117 118 reprompt = kwargs["reprompt"] 119 del kwargs["reprompt"] 120 validate = kwargs["validate"] 121 del kwargs["validate"] 122 123 prompt = args[0] 124 ret = None 125 while not ret: 126 ret = question(prompt, **kwargs) 127 if not validate(ret): 128 ret = None 129 prompt = reprompt 130 131 return ret 132 133 134def prompt_heading_subheading(heading, subheading): 135 cli.log.info(f"{{fg_yellow}}{heading}{{style_reset_all}}") 136 cli.log.info(subheading) 137 138 139def prompt_keyboard(): 140 prompt_heading_subheading("Name Your Keyboard Project", """For more information, see: 141https://docs.qmk.fm/hardware_keyboard_guidelines#naming-your-keyboard-project""") 142 143 errmsg = 'Keyboard already exists! Please choose a different name:' 144 145 return _question("Keyboard Name?", reprompt=errmsg, validate=lambda x: not keyboard(x).exists()) 146 147 148def prompt_user(): 149 prompt_heading_subheading("Attribution", "Used for maintainer, copyright, etc.") 150 151 return question("Your GitHub Username?", default=git_get_username()) 152 153 154def prompt_name(def_name): 155 prompt_heading_subheading("More Attribution", "Used for maintainer, copyright, etc.") 156 157 return question("Your Real Name?", default=def_name) 158 159 160def prompt_layout(): 161 prompt_heading_subheading("Pick Base Layout", """As a starting point, one of the common layouts can be used to 162bootstrap the process""") 163 164 # avoid overwhelming user - remove some? 165 filtered_layouts = [x for x in available_layouts if not any(xs in x for xs in ['_split', '_blocker', '_tsangan', '_f13'])] 166 filtered_layouts.append("none of the above") 167 168 return choice("Default Layout?", filtered_layouts, default=len(filtered_layouts) - 1) 169 170 171def prompt_mcu_type(): 172 prompt_heading_subheading( 173 "What Powers Your Project", """Is your board using a separate development board, such as a Pro Micro, 174or is the microcontroller integrated onto the PCB? 175 176For more information, see: 177https://docs.qmk.fm/compatible_microcontrollers""" 178 ) 179 180 return yesno("Using a Development Board?") 181 182 183def prompt_dev_board(): 184 prompt_heading_subheading("Select Development Board", """For more information, see: 185https://docs.qmk.fm/compatible_microcontrollers""") 186 187 return choice("Development Board?", dev_boards, default=dev_boards.index("promicro")) 188 189 190def prompt_mcu(): 191 prompt_heading_subheading("Select Microcontroller", """For more information, see: 192https://docs.qmk.fm/compatible_microcontrollers""") 193 194 # remove any options strictly used for compatibility 195 filtered_mcu = [x for x in mcu_types if not any(xs in x for xs in ['cortex', 'unknown'])] 196 197 return choice("Microcontroller?", filtered_mcu, default=filtered_mcu.index("atmega32u4")) 198 199 200@cli.argument('-kb', '--keyboard', help='Specify the name for the new keyboard directory', arg_only=True, type=keyboard_name) 201@cli.argument('-l', '--layout', help='Community layout to bootstrap with', arg_only=True, type=layout_type) 202@cli.argument('-t', '--type', help='Specify the keyboard MCU type (or "development_board" preset)', arg_only=True, type=mcu_type) 203@cli.argument('-u', '--username', help='Specify your username (default from Git config)', dest='name') 204@cli.argument('-n', '--realname', help='Specify your real name if you want to use that. Defaults to username', arg_only=True) 205@cli.subcommand('Creates a new keyboard directory') 206def new_keyboard(cli): 207 """Creates a new keyboard. 208 """ 209 cli.log.info('{style_bright}Generating a new QMK keyboard directory{style_normal}') 210 cli.echo('') 211 212 kb_name = cli.args.keyboard if cli.args.keyboard else prompt_keyboard() 213 if not validate_keyboard_name(kb_name): 214 cli.log.error('Keyboard names must contain only {fg_cyan}lowercase a-z{fg_reset}, {fg_cyan}0-9{fg_reset}, and {fg_cyan}_{fg_reset}! Please choose a different name.') 215 return 1 216 217 if keyboard(kb_name).exists(): 218 cli.log.error(f'Keyboard {{fg_cyan}}{kb_name}{{fg_reset}} already exists! Please choose a different name.') 219 return 1 220 221 user_name = cli.config.new_keyboard.name if cli.config.new_keyboard.name else prompt_user() 222 real_name = cli.args.realname or cli.config.new_keyboard.name if cli.args.realname or cli.config.new_keyboard.name else prompt_name(user_name) 223 default_layout = cli.args.layout if cli.args.layout else prompt_layout() 224 225 if cli.args.type: 226 mcu = cli.args.type 227 else: 228 mcu = prompt_dev_board() if prompt_mcu_type() else prompt_mcu() 229 230 config = {} 231 if mcu in dev_boards: 232 config['development_board'] = mcu 233 else: 234 config['processor'] = mcu 235 config['bootloader'] = select_default_bootloader(mcu) 236 237 detach_layout = False 238 if default_layout == 'none of the above': 239 default_layout = "ortho_4x4" 240 detach_layout = True 241 242 tokens = { # Comment here is to force multiline formatting 243 'YEAR': str(date.today().year), 244 'KEYBOARD': kb_name, 245 'USER_NAME': user_name, 246 'REAL_NAME': real_name 247 } 248 249 # begin with making the deepest folder in the tree 250 keymaps_path = keyboard(kb_name) / 'keymaps/' 251 keymaps_path.mkdir(parents=True) 252 253 # copy in keymap.c or keymap.json 254 community_keymap = Path(COMMUNITY / f'{default_layout}/default_{default_layout}/') 255 shutil.copytree(community_keymap, keymaps_path / 'default') 256 257 # process template files 258 for file in list(TEMPLATE.iterdir()): 259 replace_placeholders(file, keyboard(kb_name) / file.name, tokens) 260 261 # merge in infos 262 community_info = Path(COMMUNITY / f'{default_layout}/info.json') 263 augment_community_info(config, community_info, keyboard(kb_name) / 'keyboard.json') 264 265 # detach community layout and rename to just "LAYOUT" 266 if detach_layout: 267 replace_string(keyboard(kb_name) / 'keyboard.json', 'LAYOUT_ortho_4x4', 'LAYOUT') 268 replace_string(keymaps_path / 'default/keymap.c', 'LAYOUT_ortho_4x4', 'LAYOUT') 269 270 cli.log.info(f'{{fg_green}}Created a new keyboard called {{fg_cyan}}{kb_name}{{fg_green}}.{{fg_reset}}') 271 cli.log.info(f"Build Command: {{fg_yellow}}qmk compile -kb {kb_name} -km default{{fg_reset}}.") 272 cli.log.info(f'Project Location: {{fg_cyan}}{QMK_FIRMWARE}/{keyboard(kb_name)}{{fg_reset}}.') 273 cli.log.info("{fg_yellow}Now update the config files to match the hardware!{fg_reset}")