Skip to content

Instantly share code, notes, and snippets.

@caksoylar
Last active October 3, 2024 23:14
Show Gist options
  • Save caksoylar/1f6809446ab2415d4116882ed1c60db2 to your computer and use it in GitHub Desktop.
Save caksoylar/1f6809446ab2415d4116882ed1c60db2 to your computer and use it in GitHub Desktop.
ZMK studio proposed physical layout definition visualizer + converter to devicetree
"""
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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment