There is now a web app to do physical layout conversions and visualize the layouts at:
https://zmk-physical-layout-converter.streamlit.app/
These scripts should work but may be buggy since I'll be maintaining the app instead.
There is now a web app to do physical layout conversions and visualize the layouts at:
https://zmk-physical-layout-converter.streamlit.app/
These scripts should work but may be buggy since I'll be maintaining the app instead.
| """ | |
| Parse a DTS file containing ZMK Studio style physical layout definitions, | |
| then output both a QMK-style json file containing all layouts and an SVG for each defined layout. | |
| Requires latest `keymap-drawer` package: | |
| pip install keymap-drawer@git+https://github.com/caksoylar/keymap-drawer.git | |
| Then run with these args: | |
| python dt_layout_viz.py layout.dts layout.json layout_svg | |
| """ | |
| import sys | |
| import json | |
| from keymap_drawer.draw import KeymapDrawer | |
| from keymap_drawer.config import DrawConfig | |
| from keymap_drawer.physical_layout import QmkLayout | |
| from keymap_drawer.parse.dts import DeviceTree | |
| physical_attr_phandles = {"&key_physical_attrs"} | |
| def main(): | |
| in_file = sys.argv[1] | |
| out_json = sys.argv[2] | |
| out_svg = sys.argv[3] | |
| with open(in_file) as f: | |
| content = f.read() | |
| dts = DeviceTree(content, in_file, True) | |
| def parse_binding_params(bindings): | |
| params = {k: int(v.lstrip("(").rstrip(")")) / 100 for k, v in zip(("w", "h", "x", "y", "r", "rx", "ry"), bindings)} | |
| if params["r"] == 0: | |
| del params["rx"], params["ry"] | |
| return params | |
| bindings_to_position = {"key_physical_attrs": parse_binding_params} | |
| print("Found position bindings:", list(bindings_to_position)) | |
| if not (nodes := dts.get_compatible_nodes("zmk,physical-layout")): | |
| raise ValueError("No zmk,physical-layout nodes found") | |
| defined_layouts = {node.get_string("display-name"): node.get_phandle_array("keys") for node in nodes} | |
| print("Found defined layouts:", list(defined_layouts)) | |
| out_layouts = {} | |
| for display_name, position_bindings in defined_layouts.items(): | |
| keys = [] | |
| for binding in position_bindings: | |
| binding = binding.split() | |
| assert binding[0].lstrip("&") in bindings_to_position, f"Unrecognized position binding {binding[0]}" | |
| keys.append(bindings_to_position[binding[0].lstrip("&")](binding[1:])) | |
| qmk_layout = QmkLayout(layout=keys) | |
| physical_layout = qmk_layout.generate(60) | |
| out_layouts[display_name] = { | |
| "layout": qmk_layout.model_dump(exclude_defaults=True, exclude_unset=True)["layout"] | |
| } | |
| print(f"Outputting SVG for layout name {display_name} to {out_svg}.{display_name}.svg") | |
| with open(f"{out_svg}.{display_name}.svg", "w") as f_svg: | |
| drawer = KeymapDrawer( | |
| config=DrawConfig(), | |
| out=f_svg, | |
| layers={display_name: list(range(len(physical_layout)))}, | |
| layout=physical_layout, | |
| ) | |
| drawer.print_board() | |
| print(f"Outputting json for all layouts to {out_json}") | |
| with open(out_json, "w") as f_j: | |
| json.dump({"layouts": out_layouts}, f_j, indent=2) | |
| if __name__ == "__main__": | |
| main() |
| / { | |
| ten_u_layout: ten_u_layout { | |
| compatible = "zmk,physical-layout"; | |
| display-name = "10u"; | |
| transform = <&ten_u_transform>; | |
| kscan = <&kscan0>; | |
| compatible = "zmk,layout"; | |
| display-name = "Standard"; | |
| keys | |
| = <&key_physical_attrs 100 100 0 0 0 0 0> | |
| , <&key_physical_attrs 100 150 100 0 0 0 0> | |
| , <&key_physical_attrs 100 100 400 100 0 0 0> | |
| , <&key_physical_attrs 100 300 200 0 (-9000) 300 0> | |
| , <&key_physical_attrs 100 100 100 100 4500 100 100> | |
| , <&key_physical_attrs 100 100 500 100 0 0 0> | |
| , <&key_physical_attrs 100 200 500 200 3000 500 200> | |
| ; | |
| }; | |
| reduced_layout: reduced_layout { | |
| compatible = "zmk,physical-layout"; | |
| display-name = "Reduced"; | |
| keys | |
| = <&key_physical_attrs 100 100 0 0 0 0 0> | |
| , <&key_physical_attrs 100 150 100 0 0 0 0> | |
| ; | |
| }; | |
| }; |
| """ | |
| Given a physical keyboard layout definition (either via QMK info files or using a parametrized | |
| ortho layout), output it in ZMK Studio devicetree representation. | |
| Requires latest `keymap-drawer` package: | |
| pip install keymap-drawer@git+https://github.com/caksoylar/keymap-drawer.git | |
| Then run with these args to print snippet to stdout: | |
| python physical_layout_to_dt.py -k ferris/sweep # using QMK keyboard name | |
| python physical_layout_to_dt.py -n "33333+2 2+33333" # using cols+thumbs notation | |
| Supported physical layout types are the same as `keymap draw`, see the `--help` output. | |
| """ | |
| import json | |
| from argparse import ArgumentParser, Namespace | |
| from pathlib import Path | |
| import yaml | |
| from keymap_drawer.config import DrawConfig | |
| from keymap_drawer.physical_layout import layout_factory, QmkLayout, _get_qmk_info | |
| DT_TEMPLATE = """ | |
| keys // w h x y rot rx ry | |
| = {key_attrs_string} | |
| ; | |
| """ | |
| KEY_TEMPLATE = "<&key_physical_attrs {w:>3d} {h:>3d} {x:>4d} {y:>4d} {rot} {rx:>4d} {ry:>4d}>" | |
| def generate_dt(args: Namespace) -> None: | |
| """Write the physical layout in devicetree format to stdout.""" | |
| if args.qmk_keyboard or args.qmk_info_json: | |
| if args.qmk_keyboard: | |
| qmk_info = _get_qmk_info(args.qmk_keyboard) | |
| else: # args.qmk_info_json | |
| assert args.qmk_info_json is not None | |
| with open(args.qmk_info_json, "rb") as f: | |
| qmk_info = json.load(f) | |
| if isinstance(qmk_info, list): | |
| assert args.qmk_layout is None, "Cannot use qmk_layout with a list-format QMK spec" | |
| layout = qmk_info # shortcut for list-only representation | |
| elif args.qmk_layout is None: | |
| layout = next(iter(qmk_info["layouts"].values()))["layout"] # take the first layout in map | |
| else: | |
| assert args.qmk_layout in qmk_info["layouts"], ( | |
| f'Could not find layout "{args.qmk_layout}" in QMK info.json, ' | |
| f'available options are: {list(qmk_info["layouts"])}' | |
| ) | |
| layout = qmk_info["layouts"][args.qmk_layout]["layout"] | |
| qmk_spec = QmkLayout(layout=layout) | |
| elif args.ortho_layout or args.cols_thumbs_notation: | |
| p_layout = layout_factory( | |
| DrawConfig(key_w=1, key_h=1, split_gap=1), | |
| ortho_layout=args.ortho_layout, | |
| cols_thumbs_notation=args.cols_thumbs_notation, | |
| ) | |
| qmk_spec = QmkLayout( | |
| layout=[{"x": key.pos.x, "y": key.pos.y, "w": key.width, "h": key.height} for key in p_layout.keys] | |
| ) | |
| else: | |
| raise ValueError("Need to select one of the args to specify physical layout") | |
| min_x, min_y = min(k.x for k in qmk_spec.layout), min(k.y for k in qmk_spec.layout) | |
| for key in qmk_spec.layout: | |
| key.x -= min_x | |
| key.y -= min_y | |
| if key.rx is not None: | |
| key.rx -= min_x | |
| if key.ry is not None: | |
| key.ry -= min_y | |
| def rot_to_str(rot: int) -> str: | |
| rot = int(100 * rot) | |
| if rot >= 0: | |
| return f"{rot:>7d}" | |
| return f"{'(' + str(rot) + ')':>7}" | |
| dt = DT_TEMPLATE.format( | |
| key_attrs_string="\n , ".join( | |
| KEY_TEMPLATE.format( | |
| w=int(100 * key.w), | |
| h=int(100 * key.h), | |
| x=int(100 * key.x), | |
| y=int(100 * key.y), | |
| rot=rot_to_str(key.r), | |
| rx=int(100 * (key.rx or 0)), | |
| ry=int(100 * (key.ry or 0)), | |
| ) | |
| for key in qmk_spec.layout | |
| ) | |
| ) | |
| print(dt) | |
| def main() -> None: | |
| """Parse the configuration and output devicetree layout snippet using KeymapDrawer.""" | |
| parser = ArgumentParser(description=__doc__) | |
| info_srcs = parser.add_mutually_exclusive_group() | |
| info_srcs.add_argument( | |
| "-j", | |
| "--qmk-info-json", | |
| help="Path to QMK info.json for a keyboard, containing the physical layout description", | |
| type=Path, | |
| ) | |
| info_srcs.add_argument( | |
| "-k", | |
| "--qmk-keyboard", | |
| help="Name of the keyboard in QMK to fetch info.json containing the physical layout info, " | |
| "including revision if any", | |
| ) | |
| parser.add_argument( | |
| "-l", | |
| "--qmk-layout", | |
| help='Name of the layout (starting with "LAYOUT_") to use in the QMK keyboard info file, ' | |
| "use the first defined one by default", | |
| ) | |
| parser.add_argument( | |
| "--ortho-layout", | |
| help="Parametrized ortholinear layout definition in a YAML format, " | |
| "for example '{split: false, rows: 4, columns: 12}'", | |
| type=yaml.safe_load, | |
| ) | |
| parser.add_argument( | |
| "-n", | |
| "--cols-thumbs-notation", | |
| help='Parametrized ortholinear layout definition in "cols+thumbs" notation, ' | |
| "for example '23332+2 2+33331' for an asymmetric 30 key split keyboard", | |
| ) | |
| args = parser.parse_args() | |
| generate_dt(args) | |
| if __name__ == "__main__": | |
| main() |