Skip to content

Hardware designguide

Porting a QMK keymap to a libhmk keyboard.json

This guide ports a working QMK keymap to libhmk, the open Hall-effect keyboard firmware that GRIDDLE LX runs on (series hub). The source keymap is a 5-layer 5x12 ortholinear: Colemak Mod-DH, QWERTY, RAISE, LOWER and a tri-layer ADJUST. The target is the 4-layer-per-profile structure in keyboard.json, with the profile-switch and momentary-layer keycodes libhmk provides and the advanced-key features (Dynamic Keystroke and Macro) that fill the gaps where QMK’s chorded keycodes (S(kc), LCTL(LALT(kc))) have no equivalent.

The port is a series of decisions carried out in a text editor, not a script. This guide was written from porting the author’s own daily-driver QMK keymap for a Boardsource Equals 601 to keyboard.json in the keyboard’s firmware repo; the board has not arrived yet, so the keymap has been validated as building and passing the schema check, but it has NOT been typed on hardware. That comes with the bring-up guide later.

Prerequisites: the keyboard’s keyboard.json already exists and validates against libhmk’s schema (make fw-validate), the QMK keymap you are porting is in a source file you can read, and you have the libhmk keycodes header open for reference. The pinned libhmk version is v1.8.1 (include/keycodes.h); keycode values are from that file.

Everything that follows is a consequence of these facts. If your keymap uses a feature not in the table, check the libhmk header before assuming it is absent.

QMK featurelibhmk equivalentEvidence in libhmk v1.8.1Status
Basic keycodes (KC_A, KC_1, etc.)Identical aliasesinclude/keycodes.h:25-135 plus aliases (KC_ENT, KC_ESC, KC_DEL)Exact
Modifier keycodes (KC_LCTL, KC_LSFT, etc.)Identical aliasesinclude/keycodes.h:141-156 plus aliasesExact
Media keycodes (KC_MUTE, KC_MPLY, etc.)Identical aliasesinclude/keycodes.h:166-179Exact
System keycodes (KC_PWR, KC_SLEP, KC_WAKE)Identical aliasesinclude/keycodes.h:161-164Exact
KC_NO / XXXXXXXKC_NO = 0x00include/keycodes.h:21, 213Exact
KC_TRANSPARENT / _______KC_TRANSPARENT = 0x01include/keycodes.h:22, 214Exact
MO(layer) momentary layerMO(n) = `SP_MO_MINn (0xC0`+n)include/keycodes.h:323-324
MO layer limit8 layers (SP_MO_MIN..SP_MO_MAX = 0xC0..0xC7)include/keycodes.h:193-194Exact
QK_BOOT (bootloader)SP_BOOT = 0xD4include/keycodes.h:205Exact
PF(profile) profile switchPF(n) = `SP_PF_MINn (0xC8`+n)include/keycodes.h:326-327
DF(layer) set default layerNo equivalent; SP_LAYER_LOCK (0xD1) toggles between current and layer 0src/layout.c:63-67Replaced with PF(n)
S(KC_*) shifted keycodesNo chorded keycode; use Dynamic Keystroke advanced key binding KC_LSFT+key on pressinclude/common.h:162-181 (DKS per-keystroke-phase bindings)Replaced
LCTL(LALT(KC_F11)) chord macroNo chorded keycode; use Dynamic Keystroke advanced key binding the two modifiers and KC_F11 on pressinclude/common.h:162-181Replaced
RM_TOGG/NEXT/PREV (rotary encoder mode)No rotary encoder on the target boardN/ADropped
Tri-layer state (ADJUST = LOWER + RAISE)libhmk highest-active-layer-wins; emulate tri-layer by putting MO(3) on the other thumb key in each of the RAISE and LOWER layerssrc/layout.c:29-43: 31 - __builtin_clz(layer_mask)Emulated
Layer count4 layers per profile (schema cap at 8; keyboard’s num_layers = 4)keyboard.json:12, schema keyboard.py:35 (1 <= num_layers <= 8)Reduced (5->4)

libhmk stores one keymap per profile (keymaps in keyboard.json), and PF(n) switches the active profile. The target keyboard has num_profiles: 4 and num_layers: 4. The port uses two profiles:

ProfileBase layerWhen active
0Colemak Mod-DHdefault; profiles 2 and 3 are copies
1QWERTYswitch with PF(1)

Each profile has four layers: 0 = base, 1 = RAISE, 2 = LOWER, 3 = ADJUST. The ADJUST layer is reached by holding the RAISE and LOWER thumb keys together: on the base layer the LOWER thumb is MO(2) and the RAISE thumb MO(1), and each of those layers has MO(3) on the other thumb. This is the emulated tri-layer described in Step 3.

A profile carries the per-key actuation, rapid-trigger and calibration settings as well, so when you tune profile 0 in the configurator, copy the settings to profile 1.

The source keymap is a 5-layer 5x12 ortholinear, key index row * 12 + col. Layer definitions from keymap.c:

  • _COLEMAK (layer 0): Colemak Mod-DH base
  • _QWERTY (layer 1): QWERTY base
  • _RAISE (layer 2): raised symbols and F-keys
  • _LOWER (layer 3): lower symbols, F-keys, media, KVM chords
  • _ADJUST (layer 4): bootloader, power keys, default-layer switch

The source uses QMK’s update_tri_layer_state(state, _LOWER, _RAISE, _ADJUST) in layer_state_set_user, so holding both LOWER and RAISE produces _ADJUST.

The Colemak and QWERTY layers differ only in alphas; the bottom row is identical: LCTL, LGUI, LALT, APP, LOWER(thumb), SPC, SPC, RAISE(thumb), LEFT, DOWN, UP, RGHT. The keycodes in the source use QMK aliases (LOWER, RAISE, custom B_GUI, B_APP, R_GUI, R_APP from compile-time variants); in libhmk those expand to their concrete values.

Step 2: map the base layers (Colemak and QWERTY)

Section titled “Step 2: map the base layers (Colemak and QWERTY)”

Every keycode on the Colemak and QWERTY layers is a basic keycode or modifier that libhmk carries under the same name. No changes needed. Write them into two profiles.

libhmk keycode array format: the keymaps field in keyboard.json is an array of profiles, each an array of layers, each an array of 60 keycode strings:

"keymaps": [
[ ["KC_GRV","KC_1",...], [...], [...], [...] ],
[ [...], [...], [...], [...] ],
...
]

Profile 0 (Colemak) layer 0:

GRV 1 2 3 4 5 6 7 8 9 0 BSPC
TAB Q W F P B J L U Y SCLN DEL
ESC A R S T G M N E I O QUOT
LSFT Z X C D V K H COMM DOT SLSH ENT
LCTL LGUI LALT APP MO(2)SPC SPC MO(1)LEFT DOWN UP RGHT

Profile 1 (QWERTY) layer 0: same grid, QWERTY alphas (Q W E R T Y U I O P, A S D F G H J K L).

Step 3: map RAISE and LOWER, and emulate tri-layer ADJUST

Section titled “Step 3: map RAISE and LOWER, and emulate tri-layer ADJUST”

The source RAISE and LOWER layers both carry shifted symbols on row 0 (S(KC_GRV), S(KC_1), … for ~!@#$%^&*()), and RAISE also on row 2 (S(KC_MINS) = _, S(KC_EQL) = +, S(KC_LBRC) = {, S(KC_RBRC) = }, S(KC_BSLS) = |). libhmk has no S(kc) macro - the keycode is a single 8-bit value (uint8_t, include/eeconfig.h:65), and KC_1 is 0x1C regardless of whether Shift is held.

Set the keycode to the base key (KC_1, not S(KC_1)) in keyboard.json, and create a Dynamic Keystroke (DKS) advanced key for each position that binds KC_LSFT + the key on press. DKS is configured in the web configurator, not in keyboard.json. A DKS advanced key binds up to NUM_DYNAMIC_KEYSTROKE_MAX_BINDINGS keycodes with a per-keystroke-phase action bitmap (include/common.h:162-181):

  • DKS_ACTION_PRESS: the keycode fires when the key is pressed past its actuation point
  • DKS_ACTION_RELEASE: the keycode fires when the key is released above the actuation point

For ! (Shift+1): create an advanced key of type Dynamic Keystroke, set keycode 0 to KC_LSFT and keycode 1 to KC_1, both with the Press action. The configurator handles this; the guide only notes that it must be done.

Count the advanced keys against num_advanced_keys (32 in the target keyboard). An advanced key is bound to one layer and one key (advanced_key_t has layer and key fields, include/common.h:232-233), and each profile has its own array of them (include/eeconfig.h:67). Per profile that is 29 of 32: 11 shifted symbols on RAISE row 0, the same 11 on LOWER row 0, 5 on RAISE row 2, and the 2 KVM chords. Profiles 0 and 1 each need the full set.

libhmk has no tri-layer feature. The fix uses libhmk’s highest-active-layer-wins behaviour: the RAISE layer (layer 1) has MO(3) on the LOWER thumb key position, and the LOWER layer (layer 2) has MO(3) on the RAISE thumb key position. When both thumb keys are held, layer 3 (ADJUST) activates in either press order because libhmk caches the keycode at press time (active_keycodes, src/layout.c:184) and resolves transparent keys down through the active layers (src/layout.c:80-93).

Row 4, columns 4 to 7, on the three layers involved:

layer 0 (base) MO(2) SPC SPC MO(1)
layer 1 (RAISE) MO(3) SPC SPC _______
layer 2 (LOWER) _______ SPC SPC MO(3)

Holding both thumbs in either order reaches ADJUST.

The QMK LOWER layer row 4 has RM_TOGG RM_NEXT RM_PREV KVM_MAC KVM_PC - rotary encoder keys (dropped; no encoder on the target) and two chord macros (LCTL(LALT(KC_F11)) and LCTL(LALT(KC_F12)) for KVM switching). The ADJUST layer has QK_BOOT KC_PWR KC_SLEP KC_WAKE DF(_QWERTY) DF(_COLEMAK).

libhmk port:

  • LOWER row 4: _______ _______ _______ _______ _______ KC_SPC KC_SPC MO(3) _______ KC_F11 KC_F12 _______
  • ADJUST row 4: SP_BOOT KC_PWR KC_SLEP KC_WAKE _______ _______ _______ _______ PF(1) PF(0) _______ _______

The DF(_QWERTY) and DF(_COLEMAK) keys become PF(1) and PF(0) - profile switches instead of default-layer switches. Caveat: DF() changes the default layer within one profile; PF() switches the whole profile, including the per-key actuation settings. Tune profile 1’s actuation the same as profile 0’s in the configurator.

The KVM chords (LCTL(LALT(KC_F11)), LCTL(LALT(KC_F12))) are placeholders at KC_F11 / KC_F12 on the LOWER layer. As DKS advanced keys: bind KC_LCTL, KC_LALT and KC_F11 (or KC_F12) all on Press.

SP_BOOT sits on ADJUST alone (the thumb-hold combination is the gate). The source had QK_BOOT on the ADJUST bottom-left; the port puts SP_BOOT in the same position, requiring both thumb keys to be held to reach it.

The final keymap in keyboard.json, profile 0 (Colemak), layer by layer:

  • Layer 0: Colemak base (identical to QMK, keycode for keycode)
  • Layer 1 (RAISE): row 0 with plain digits (no S()), row 2 with plain symbols (KC_MINS not S(KC_MINS)), MO(3) at the LOWER thumb position
  • Layer 2 (LOWER): same row 0/2 as RAISE, row 4 with MO(3) at the RAISE thumb position and KC_F11/KC_F12 placeholders for the KVM chords
  • Layer 3 (ADJUST): SP_BOOT KC_PWR KC_SLEP KC_WAKE at cols 0-3, PF(1) and PF(0) at cols 8-9

Every keycode uses the KC_ prefix string form libhmk’s schema expects. The _______ alias (KC_TRANSPARENT) is used exactly as in the QMK source. After writing, run make fw-validate to check the JSON against the libhmk pydantic schema and make fw to confirm the firmware builds.

CheckCommandResult
Schema validationmake fw-validatekeyboard.json ok (griddle)
Firmware buildsmake fwSUCCESS, Flash 12.9%
Keymap round-tripmake keymap-jsonfile unchanged after regeneration
Gatemake checkpassed
Typed on hardwarenot donethe board has not arrived; bring-up guide later
  1. QWERTY was unreachable in the first port. It put QWERTY on layer 1 of the same profile, but libhmk has no DF() to switch default layers, so nothing could reach it. The fix was separate profiles: PF(1) switches profile, and each profile has its own base layer.

  2. SP_BOOT sat behind one thumb key in the first port. It was on the LOWER layer directly, one thumb hold from an accidental bootloader jump. The source needs LOWER and RAISE together (ADJUST) to reach it; moving it to the emulated ADJUST layer restored that.

  3. Tri-layer is emulated, not native. libhmk activates the highest numbered active layer. Putting MO(3) on both thumb keys (RAISE has it at the LOWER position, LOWER has it at the RAISE position) means holding both in either order activates layer 3. Tested by reasoning through src/layout.c:80-93 and src/layout.c:184 - the keycode at press time is cached, and transparent keys resolve down through the layer stack. The ADJUST keys fire only when both thumbs are held.

  4. A profile carries more than the keymap. PF(n) switches the whole profile, including per-key actuation, rapid-trigger and calibration. Copy the tuned profile 0 settings to profile 1 in the configurator, or the QWERTY profile will type with different actuation points.

  5. Shifted symbols need DKS, not S(kc). libhmk’s keycode is one 8-bit field (uint8_t). KC_1 is 0x1C, and Shift is a separate keycode. A key that types ! must bind KC_LSFT + KC_1 via DKS in the configurator. The same applies to the KVM chords (LCTL+LALT+F11/F12). num_advanced_keys (32 in the target keyboard) is the per-profile budget for all advanced keys of every type, and this port uses 29 of it.

  6. LY_LOCK toggles, PF() switches. SP_LAYER_LOCK (0xD1, aliased as LY_LOCK) makes the currently active layer the default by swapping it with layer 0 (src/layout.c:65). It is a toggle, not a named set. If you only need Colemak and QWERTY, PF(0) / PF(1) on the ADJUST layer is the simpler and more deliberate mechanism, at the cost of carrying per-key settings in both profiles.

FileRole
firmware/keyboards/griddle/keyboard.jsonboard definition, keymap, analog and calibration
include/keycodes.h (libhmk v1.8.1)all keycode constants and macros
include/common.h:85-242 (libhmk v1.8.1)advanced key types: DKS, null bind, tap-hold, toggle, macro
include/eeconfig.h:65-67 (libhmk v1.8.1)keymap and advanced key storage layout
src/layout.c:29-93 (libhmk v1.8.1)layer resolution: highest-active-layer-wins, transparent descent
src/layout.c:184 (libhmk v1.8.1)active keycode caching at press time
src/layout.c:63-67 (libhmk v1.8.1)LY_LOCK implementation
scripts/schema/keyboard.py (libhmk v1.8.1)pydantic schema that make fw-validate checks against
keyboards/boardsource/equals/60/keymaps/erfi/keymap.c (QMK userspace)source keymap
docs/research/raw/2026-11-04-keymap-port/port.mdport record, feature table, validation output
  1. The source QMK keymap lives in the author’s QMK userspace, keyboards/boardsource/equals/60/keymaps/erfi/keymap.c. The QMK keycodes documentation is at https://docs.qmk.fm/keycodes and the tri-layer feature at https://docs.qmk.fm/features/tri_layer. ↩