Personal ZMK firmware config for the Kyria rev3 wireless split keyboard, running on Nice!Nano v2 microcontrollers.
ZMK is required for wireless split keyboard support on the Nice!Nano hardware.
- Custom OLED display — both halves show live keyboard status (layer, Bluetooth profile, battery %) rendered with a custom bitmap font and a set of inline icons
- Character count — tracks keypresses per session with comma formatting and k/m/b suffixes for large numbers
- WPM estimate — rolling 30-second window; idle time falls out naturally so it only reflects active typing; no keystroke content is stored. Formula: a "word" is counted each time a non-letter key follows a letter key (space, punctuation, Enter, etc.);
WPM = words_in_last_30s × 2. This will read lower than a standard typing test if you type short words or use many special characters, and higher if you type long words with few separators. Window duration is configurable indisplay_config.h.
- Virtual pet — a Tamagotchi-style creature living on the OLED displays, fed by keypresses
- Drawing tablet companion — one half used as a keypad alongside a Wacom tablet or iPad, keeping shortcuts accessible without a full keyboard on the desk
The firmware includes a custom Zephyr module (display_module/) that extends the OLED displays beyond ZMK's default widgets.
&display_toggle (bound to ADJ_L) cycles through three states:
| State | What you see |
|---|---|
| STOCK | ZMK default: Bluetooth status, battery %, layer number |
| CUSTOM | Real layout: info column + virtual pet area (currently a blank screen while lv_img rendering is investigated) |
| DEMO | Developer mockup images — cycle through them with &demo_cycle |
&demo_cycle is a no-op in STOCK and CUSTOM states. It only advances images when in DEMO state.
| Row | Content |
|---|---|
| Top | Split link icon · BT icon · profile number · connection icon |
| Row 2 | Battery / charging icon · battery % |
| Row 3 | OS icon · L: · layer name |
| Bottom | Status string (keycount or pet notification, scrolling marquee) |
| Right 62px | Virtual pet area |
resources/
pet/ ← pet sprites (.png source + generated .h)
icons/ ← UI icons (.png source + generated .h)
fonts/
<FontName>/ ← OFL font source + license (can be committed)
generated/ ← lv_font_conv output .c files (do not edit)
demos/ ← generated headers from demos/*.png (do not edit)
demos/ ← source PNG mockups and font reference images
tools/
convert_image.py ← PNG → LVGL image header
gen_demos.py ← converts all demos/*.png → demo_list.h
png_to_icon_font.py ← PNG icon → single-glyph TTF (for inline text icons)
build_font.sh ← rebuilds all font .c files (run after font changes)
display_module/src/
display_config.h ← all user-tunable constants (edit here)
&bt_switch 0 (next profile) and &bt_switch 1 (previous profile) replace ZMK's built-in &bt BT_NXT / &bt BT_PRV. When you switch profiles, only the non-target profiles are disconnected — the target device stays connected for a seamless handoff. Departing devices receive a proper BLE disconnect so their virtual keyboards reappear.
&bt_clear replaces &bt BT_CLR. It clears the current profile's bond and resets its stored OS layer.
Each BT profile remembers which base layer (Windows or Mac) was active when you last used it. Switching profiles automatically restores the remembered layer. Configure in config/kyria_rev3.conf:
# Persist the remembered base layer (Windows/Mac) for each BT profile to NVS.
# Requires base-layers to be set on the bt_switch behavior node in the keymap.
CONFIG_BT_SWITCH_PERSIST_BASE_LAYER=y
# Restore the last active BT profile on boot, so the keyboard reconnects to
# whichever device you were using before powering off.
CONFIG_BT_SWITCH_PERSIST_ACTIVE_PROFILE=y
To change which layers are tracked as OS/layout defaults, edit the base-layers property on the bt_switch behavior node in config/kyria_rev3.keymap:
bt_switch: bt_switch {
compatible = "zmk,behavior-bt-switch";
#binding-cells = <1>;
base-layers = <WINDOWS_L MAC_L>; // first entry is always-on default; omit to disable tracking
};Both options default to n. With both enabled, the full boot sequence is: load saved profile → restore OS layer → start connecting.
Layer names shown on the OLED are defined as a compile-time array in display_module/src/display_config.h. The index in the array matches the #define layer number at the top of config/kyria_rev3.keymap.
Edit LAYER_NAMES_LIST in display_config.h:
#define LAYER_NAMES_LIST \
"BASE", /* 0 WINDOWS_L */ \
"BASE", /* 1 MAC_L */ \
"NUM", /* 2 NUM_L */ \
"MOVE", /* 3 MOV_L */ \
"SYMB", /* 4 SC_L */ \
"FUNC", /* 5 ADJ_L */ \
NULL, /* 6 unused — shows raw number */ \
NULL, /* 7 unused — shows raw number */- Max readable length at the current font size is ~5 characters before the label clips.
NULLentries fall back to the raw layer number (e.g.L:6).- Push to GitHub — Actions rebuilds the firmware automatically.
Runtime renaming (type a name on the keyboard, persist to flash) is planned but not yet implemented. See PLANNED.md.
All position, spacing, and font constants are in display_module/src/display_config.h. Edit the relevant constant and push — no C code changes needed.
Key constants:
| Constant | What it controls |
|---|---|
PET_AREA_X |
Where the info column ends and pet area begins (default: 66px) |
ROW_TOP_Y / ROW_BATTERY_Y / ROW_LAYER_Y |
Vertical position of each info row |
ICON_TEXT_GAP |
Gap in pixels between an icon and the text after it |
STATUS_MARQUEE_SPEED |
Pixels per second for the bottom status scroll |
STATUS_ABBREV_THRESHOLD |
Keycount value above which numbers abbreviate (e.g. 1,000k) |
FONT_BATTERY_NUM / FONT_LAYER_L / etc. |
Which font variable each element uses |
- Find an OFL-licensed font — OFL allows committing the
.ttfto a public repo with attribution. - Place the
.ttfand itsLicense.txtinresources/fonts/<FontName>/. - Edit the
FONT_SRCline intools/build_font.shto point to the new file. - Run
bash tools/build_font.shfrom the project root. - Commit everything under
resources/fonts/andresources/fonts/generated/, then push.
The font variable names in display_config.h (FONT_BATTERY_NUM, FONT_LAYER_L, etc.) do not need to change — they reference the same generated file names.
Bold: BadComic has no bold TTF variant. A fake-bold post-processor (1px right-shift + OR on the bitmap) is planned. See
build_font.shfor the TODO stub.
Adding or changing a size requires updates to three files (build_font.sh, CMakeLists.txt, display_config.h). See DESIGN.md → Font pipeline for the step-by-step workflow.
UI icons appear as standalone image objects (BT icon, battery icon, link icon, etc.).
- Draw the icon as a PNG, 13×14px with a 1px transparent bleed on all sides (so the visible art is 12×13px). The bleed pixel is placed off-screen when the icon is at a screen edge.
- Place the PNG in
resources/icons/. - Convert it:
python3 tools/convert_image.py resources/icons/my_icon.png > resources/icons/my_icon.h - In
display_module/src/custom_display.c, add#include "my_icon.h"at the top and reference&my_iconwhere you want to display it. - Push to GitHub.
Inline icons appear inside the scrolling status string at the bottom of the display (e.g., a currency symbol before the keycount). They require a separate pipeline from UI icons because they must live inside the text font.
- Draw the icon as a PNG, 13×14px with 1px bleed (same as UI icons).
- Place the PNG in
resources/icons/. - Open
tools/build_font.shand add an entry under the icon TTF section:Use the next available code point (python3 "$TOOLS/png_to_icon_font.py" \ "$ICONS/my_icon.png" "$TMP/my_icon.ttf" --codepoint 0xE002
0xE001is taken by the currency icon, so start at0xE002). - Add the new TTF as an additional
--fontsource in thefont_badcomic_11build command inbuild_font.sh:--font "$TMP/my_icon.ttf" --range "0xE002"
- Run
bash tools/build_font.sh. - Add a macro for the UTF-8 escape sequence in
display_config.h:(UTF-8 for U+E002 is#define STATUS_ICON_MY_ICON "\xEE\x80\x82" // U+E002
EE 80 82.) - Use the macro in any status string:
STATUS_ICON_MY_ICON "text here". - Commit
resources/fonts/generated/and push.
To add a test image to the on-device demo cycle (DEMO display state):
- Drop the PNG in
demos/. - Run
python3 tools/gen_demos.pyfrom the project root. - Commit the generated files in
resources/demos/before pushing.
gen_demos.py converts every PNG in demos/ and regenerates resources/demos/demo_list.h.
# Single image
python3 tools/convert_image.py image.png > resources/pet/my_image.h
# Sprite sheet (e.g. 4 frames, 60×60 each)
python3 tools/convert_image.py sheet.png --sprite-w 60 --sprite-h 60 \
--names idle_0 idle_1 walk_0 walk_1 > resources/pet/sprites.hImages must be PNG. The display is monochrome — pixels with luminance ≥ 128 render as white (lit), below 128 as black (off).
The module path in build.yaml is hardcoded to this machine. To build with GitHub Actions CI, the module needs to be extracted to a separate GitHub repo and referenced in config/west.yml.
See DESIGN.md for settled decisions and technical architecture. See PLANNED.md for features in progress.
MIT — see LICENSE. You are free to use, modify, and distribute this config and the custom display module for any purpose, including commercial, as long as the copyright notice is retained.
All art assets (icons, sprites, and other original graphics in resources/) are original works by the author and are also released under the MIT license.
- ZMK Firmware — the wireless keyboard firmware this config runs on. ZMK is MIT licensed; see their repository for full copyright and license details.
- BadComic font — display font used on the OLEDs, distributed under the SIL Open Font License (OFL).
- Splitkb Kyria rev3 — the keyboard hardware this config targets.
- Claude Code (Anthropic) — assisted with implementation of the custom display module, typing stats, asset tooling, and documentation under directed oversight. All design decisions, requirements, and hardware validation are by the author.



