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.
Constants (read this first)
Section titled “Constants (read this first)”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 feature -> libhmk equivalent
Section titled “QMK feature -> libhmk equivalent”| QMK feature | libhmk equivalent | Evidence in libhmk v1.8.1 | Status |
|---|---|---|---|
Basic keycodes (KC_A, KC_1, etc.) | Identical aliases | include/keycodes.h:25-135 plus aliases (KC_ENT, KC_ESC, KC_DEL) | Exact |
Modifier keycodes (KC_LCTL, KC_LSFT, etc.) | Identical aliases | include/keycodes.h:141-156 plus aliases | Exact |
Media keycodes (KC_MUTE, KC_MPLY, etc.) | Identical aliases | include/keycodes.h:166-179 | Exact |
System keycodes (KC_PWR, KC_SLEP, KC_WAKE) | Identical aliases | include/keycodes.h:161-164 | Exact |
KC_NO / XXXXXXX | KC_NO = 0x00 | include/keycodes.h:21, 213 | Exact |
KC_TRANSPARENT / _______ | KC_TRANSPARENT = 0x01 | include/keycodes.h:22, 214 | Exact |
MO(layer) momentary layer | MO(n) = `SP_MO_MIN | n (0xC0`+n) | include/keycodes.h:323-324 |
MO layer limit | 8 layers (SP_MO_MIN..SP_MO_MAX = 0xC0..0xC7) | include/keycodes.h:193-194 | Exact |
QK_BOOT (bootloader) | SP_BOOT = 0xD4 | include/keycodes.h:205 | Exact |
PF(profile) profile switch | PF(n) = `SP_PF_MIN | n (0xC8`+n) | include/keycodes.h:326-327 |
DF(layer) set default layer | No equivalent; SP_LAYER_LOCK (0xD1) toggles between current and layer 0 | src/layout.c:63-67 | Replaced with PF(n) |
S(KC_*) shifted keycodes | No chorded keycode; use Dynamic Keystroke advanced key binding KC_LSFT+key on press | include/common.h:162-181 (DKS per-keystroke-phase bindings) | Replaced |
LCTL(LALT(KC_F11)) chord macro | No chorded keycode; use Dynamic Keystroke advanced key binding the two modifiers and KC_F11 on press | include/common.h:162-181 | Replaced |
RM_TOGG/NEXT/PREV (rotary encoder mode) | No rotary encoder on the target board | N/A | Dropped |
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 layers | src/layout.c:29-43: 31 - __builtin_clz(layer_mask) | Emulated |
| Layer count | 4 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) |
Profile and layer structure
Section titled “Profile and layer structure”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:
| Profile | Base layer | When active |
|---|---|---|
| 0 | Colemak Mod-DH | default; profiles 2 and 3 are copies |
| 1 | QWERTY | switch 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.
Step 1: read the QMK keymap
Section titled “Step 1: read the QMK keymap”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 BSPCTAB Q W F P B J L U Y SCLN DELESC A R S T G M N E I O QUOTLSFT Z X C D V K H COMM DOT SLSH ENTLCTL LGUI LALT APP MO(2)SPC SPC MO(1)LEFT DOWN UP RGHTProfile 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 pointDKS_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.
Tri-layer ADJUST emulation
Section titled “Tri-layer ADJUST emulation”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.
Step 4: map LOWER and merge ADJUST keys
Section titled “Step 4: map LOWER and merge ADJUST keys”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.
Step 5: write keyboard.json keymap
Section titled “Step 5: write keyboard.json keymap”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_MINSnotS(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 andKC_F11/KC_F12placeholders for the KVM chords - Layer 3 (ADJUST):
SP_BOOT KC_PWR KC_SLEP KC_WAKEat cols 0-3,PF(1)andPF(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.
Verification
Section titled “Verification”| Check | Command | Result |
|---|---|---|
| Schema validation | make fw-validate | keyboard.json ok (griddle) |
| Firmware builds | make fw | SUCCESS, Flash 12.9% |
| Keymap round-trip | make keymap-json | file unchanged after regeneration |
| Gate | make check | passed |
| Typed on hardware | not done | the board has not arrived; bring-up guide later |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”-
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. -
SP_BOOTsat 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. -
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 throughsrc/layout.c:80-93andsrc/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. -
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. -
Shifted symbols need DKS, not
S(kc). libhmk’s keycode is one 8-bit field (uint8_t).KC_1is0x1C, and Shift is a separate keycode. A key that types!must bindKC_LSFT+KC_1via 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. -
LY_LOCKtoggles,PF()switches.SP_LAYER_LOCK(0xD1, aliased asLY_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.
File reference
Section titled “File reference”| File | Role |
|---|---|
firmware/keyboards/griddle/keyboard.json | board 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.md | port record, feature table, validation output |
References
Section titled “References”-
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. ↩