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() |