Skip to main content

New Behavior

Overview

This document outlines how to develop a behavior for ZMK and prepare the changes for a pull request.

Behaviors are assigned to key positions and determine what happens when they are pressed and released. They are implemented in Zephyr as "devices": they consist of a devicetree binding file, which specifies the properties of the behavior, and a driver written in C code. This allows for the ability to create unique instances of these behaviors in keymaps or devicetree-source-include files (.dtsi). While instances of behaviors stored in keymaps are created by end-users for their personal needs, the instances that live in the .dtsi files are stored and documented in ZMK directly, which removes the need for end-users to set up common use-cases of these behaviors in their personal keymaps.

The general process for developing behaviors is:

  1. Create the behavior
    1. Create the devicetree binding (.yaml)
    2. Create the driver (.c)
    3. Update app/CmakeLists.txt to include the new driver
    4. Define common use-cases for the behavior (.dtsi) (Optional)
  2. Test changes locally
  3. Document behavior functionality
  4. Create a pull request for review and inclusion into the ZMK sources
info

Before developing new behaviors, developers should have a working knowledge of the Embedded Linux Devicetree. The following resources are provided for those seeking further understanding:

Creating the Behavior

Creating the devicetree binding (.yaml)

The properties of the behavior are listed in the behavior's devicetree binding, which comes in the form of a .yaml file. Devicetree bindings are stored in the directory app/dts/bindings/behaviors/ and are labelled in lowercase, beginning with the prefix zmk,behavior-, and ending with the behavior's name, using dashes to separate multiple words. For example, the directory for the hold-tap's devicetree binding would be located at app/dts/bindings/behaviors/zmk,behavior-hold-tap.yaml, which is shown below as a reference:

app/dts/bindings/behaviors/zmk,behavior-hold-tap.yaml
# Copyright (c) 2020 The ZMK Contributors
# SPDX-License-Identifier: MIT

description: Hold or Tap behavior

compatible: "zmk,behavior-hold-tap"

include: two_param.yaml

properties:
bindings:
type: phandles
required: true
tapping-term-ms:
type: int
tapping_term_ms: # deprecated
type: int
quick-tap-ms:
type: int
default: -1
quick_tap_ms: # deprecated
type: int
flavor:
type: string
required: false
default: "hold-preferred"
enum:
- "hold-preferred"
- "balanced"
- "tap-preferred"
- "tap-unless-interrupted"
retro-tap:
type: boolean
hold-trigger-key-positions:
type: array
required: false
default: []

We see that the .yaml files used for new behaviors' devicetree bindings consist of the following properties:

description

A brief statement of what the behavior is. The value of this property is not seen by end-users; as such, the description value should be kept less than a sentence long, leaving explanations for end-users of how the behavior works for its documentation.

compatible

Allows ZMK to assign the correct driver to the behavior extracted from the keymap or .dtsi. The value of the compatible property is equal to the name of the devicetree binding file as a string.

As shown in the example above, compatible: "zmk,behavior-hold-tap" is the value of the compatible property of zmk,behavior-hold-tap.yaml.

include

Choose between zero_param.yaml, one_param.yaml, or two_param.yaml depending on how many additional parameters are required to complete the behavior's binding in a keymap. For example, we include: two_param.yaml in zmk,behavior-hold-tap.yaml because any user-defined or pre-defined instances of the hold-tap behavior take in two cells as inputs: one for the hold behavior and one for the tap behavior.

properties (Optional)

These are additional variables required to configure a particular instance of a behavior. properties can be of the following types:

  • path
  • compound
  • array
  • string
  • string-array
  • boolean
  • int
  • uint8-array
  • phandle.
  • phandle-array
  • phandles
info

For more information on additional properties, refer to Zephyr's documentation on Devicetree bindings.

Creating the driver (.c)

info

Developing drivers for behaviors in ZMK makes extensive use of the Zephyr Devicetree API and Device Driver Model. Links to the Zephyr Project Documentation for both of these concepts can be found below:

Driver files are stored in app/src/behaviors/ and are labelled in lowercase, beginning with the prefix behavior_, and ending with the behavior's name, using underscores to separate multiple words. For example, the directory for the hold-tap's driver would be located at app/src/behaviors/behavior_hold_tap.c.

The code snippet below shows the essential components of a new driver.

#define DT_DRV_COMPAT zmk_<behavior_name>

// Dependencies
#include <zephyr/device.h>
#include <drivers/behavior.h>
#include <zephyr/logging/log.h>

#include <zmk/behavior.h>

LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);

#if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT)

// Instance-Unique Data Struct (Optional)
struct behavior_<behavior_name>_data {
bool example_data_param1;
bool example_data_param2;
bool example_data_param3;
};

// Instance-Unique Config Struct (Optional)
struct behavior_<behavior_name>_config {
bool example_config_param1;
bool example_config_param2;
bool example_config_param3;
};

// Initialization Function
static int <behavior_name>_init(const struct device *dev) {
return 0;
};

// API Structure
static const struct behavior_driver_api <behavior_name>_driver_api = {

};

BEHAVIOR_DT_INST_DEFINE(0, // Instance Number (Equal to 0 for behaviors that don't require multiple instances,
// Equal to n for behaviors that do make use of multiple instances)
<behavior_name>_init, NULL, // Initialization Function, Power Management Device Pointer
&<behavior_name>_data, &<behavior_name>_config, // Behavior Data Pointer, Behavior Configuration Pointer (Both Optional)
POST_KERNEL, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, // Initialization Level, Device Priority
&<behavior_name>_driver_api); // API Structure

#endif /* DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT) */

DT_DRV_COMPAT

Replace zmk_<behavior_name> in the #define DT_DRV_COMPAT statement with the name of your behavior. (e.g. zmk_behavior_caps_word)

Dependencies

The dependencies required for any ZMK behavior are:

  • device.h: Zephyr Device APIs
  • drivers/behavior.h: ZMK Behavior Functions (e.g. locality, behavior_keymap_binding_pressed, behavior_keymap_binding_released, behavior_sensor_keymap_binding_triggered)
  • logging/log.h: Zephyr Logging APIs (for more information on USB Logging in ZMK, see USB Logging).
  • zmk/behavior.h: ZMK Behavior Information (e.g. parameters, position and timestamp of events)
    • return values:
      • ZMK_BEHAVIOR_OPAQUE: Used to terminate on_<behavior_name>_binding_pressed and on_<behavior_name>_binding_released functions that accept (struct zmk_behavior_binding *binding, struct zmk_behavior_binding_event event) as parameters
      • ZMK_BEHAVIOR_TRANSPARENT: Used in the binding_pressed and binding_released functions for the transparent (&trans) behavior
    • structs:
      • zmk_behavior_binding: Stores the name of the behavior device (char *behavior_dev) as a string and up to two additional parameters (uint32_t param1, uint32_t param2)
      • zmk_behavior_binding_event: Contains layer, position, and timestamp data for an active zmk_behavior_binding

Other common dependencies include zmk/keymap.h, which allows behaviors to access layer information and extract behavior bindings from keymaps, and zmk/event_manager.h which is detailed below.

ZMK Event Manager

Including zmk/event_manager.h is required for the following dependencies to function properly.

  • zmk/events/position_state_changed.h: Position events' state (on/off), source, position, and timestamps
  • zmk/events/keycode_state_changed.h: Keycode events' state (on/off), usage page, keycode value, modifiers, and timestamps
  • zmk/events/modifiers_state_changed.h: Modifier events' state (on/off) and modifier value

Events can be used similarly to hardware interrupts, through the use of listeners.

Listeners and Subscriptions

The condensed form of lines 192-225 of the tap-dance driver, shown below, does an excellent job of showcasing the function of listeners and subscriptions with respect to the ZMK Event Manager.

app/src/behaviors/behavior_tap_dance.c (Lines 192-197, 225)
static int tap_dance_position_state_changed_listener(const zmk_event_t *eh);

ZMK_LISTENER(behavior_tap_dance, tap_dance_position_state_changed_listener);
ZMK_SUBSCRIPTION(behavior_tap_dance, zmk_position_state_changed);

static int tap_dance_position_state_changed_listener(const zmk_event_t *eh){
// Do stuff...
}

Listeners, defined by the ZMK_LISTENER(mod, cb) function, take in a listener name (mod) and a callback function (cb) as their parameters. On the other hand subscriptions are defined by the ZMK_SUBSCRIPTION(mod, ev_type), and determine what kind of event (ev_type) should invoke the callback function from the listener. In the tap-dance example, this listener executes code depending on a zmk_position_state_changed event, or simply, a change in key position. Other types of ZMK events can be found as the name of the struct inside each of the files located at app/include/zmk/events/<Event Type>.h. All control paths in a listener should return one of the ZMK_EV_EVENT_* values, which are shown below.

return values:
  • ZMK_EV_EVENT_BUBBLE: Keep propagating the event struct to the next listener.
  • ZMK_EV_EVENT_HANDLED: Stop propagating the event struct to the next listener. The event manager still owns the struct's memory, so it will be freed automatically. Do not free the memory in this function.
  • ZMK_EV_EVENT_CAPTURED: Stop propagating the event struct to the next listener. The event struct's memory is now owned by your code, so the event manager will not free the event struct memory. Make sure your code will release or free the event at some point in the future. (Use the ZMK_EVENT_* macros described below.)
Macros:
  • ZMK_EVENT_RAISE(ev): Start handling this event (ev) with the first registered event listener.
  • ZMK_EVENT_RAISE_AFTER(ev, mod): Start handling this event (ev) after the event is captured by the named event listener (mod). The named event listener will be skipped as well.
  • ZMK_EVENT_RAISE_AT(ev, mod): Start handling this event (ev) at the named event listener (mod). The named event listener is the first handler to be invoked.
  • ZMK_EVENT_RELEASE(ev): Continue handling this event (ev) at the next registered event listener.
  • ZMK_EVENT_FREE(ev): Free the memory associated with the event (ev).

BEHAVIOR_DT_INST_DEFINE

BEHAVIOR_DT_INST_DEFINE is a special ZMK macro. It forwards all the parameters to Zephyr's DEVICE_DT_INST_DEFINE macro to define the driver instance, then it adds the driver to a list of ZMK behaviors so they can be found by zmk_behavior_get_binding().

info

For more information on this function, refer to Zephyr's documentation on the Device Driver Model.

The example BEHAVIOR_DT_INST_DEFINE call can be left as is with the first parameter, the instance number, equal to 0 for behaviors that only require a single instance (e.g. external power, backlighting, accessing layers). For behaviors that can have multiple instances (e.g. hold-taps, tap-dances, sticky-keys), BEHAVIOR_DT_INST_DEFINE can be placed inside a #define statement, usually formatted as #define <ABBREVIATED BEHAVIOR NAME>_INST(n), that sets up any data pointers and/or configuration pointers that are unique to each instance.

An example of this can be seen below, taking the #define KP_INST(n) from the hold-tap driver.

#define KP_INST(n)                                                                                 \
static struct behavior_hold_tap_config behavior_hold_tap_config_##n = { \
.tapping_term_ms = DT_INST_PROP(n, tapping_term_ms), \
.hold_behavior_dev = DT_PROP(DT_INST_PHANDLE_BY_IDX(n, bindings, 0), label), \
.tap_behavior_dev = DT_PROP(DT_INST_PHANDLE_BY_IDX(n, bindings, 1), label), \
.quick_tap_ms = DT_INST_PROP(n, quick_tap_ms), \
.flavor = DT_ENUM_IDX(DT_DRV_INST(n), flavor), \
.retro_tap = DT_INST_PROP(n, retro_tap), \
.hold_trigger_key_positions = DT_INST_PROP(n, hold_trigger_key_positions), \
.hold_trigger_key_positions_len = DT_INST_PROP_LEN(n, hold_trigger_key_positions), \
}; \
BEHAVIOR_DT_INST_DEFINE(n, behavior_hold_tap_init, NULL, NULL, &behavior_hold_tap_config_##n, \
APPLICATION, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, \
&behavior_hold_tap_driver_api);

DT_INST_FOREACH_STATUS_OKAY(KP_INST)

Note that in the hold-tap example, the instance number, 0, has been replaced by n, signifying the unique node_id of each instance of a behavior. Furthermore, the DT_INST_FOREACH_STATUS_OKAY(KP_INST) macro iterates through each compatible, non-disabled devicetree node, creating and applying the proper values to any instance-specific configurations or data by invoking the KP_INST macro for each instance of the new behavior.

Behaviors also require the following parameters of BEHAVIOR_DT_INST_DEFINE to be changed:

Initialization Function

Comes in the form static int <behavior_name>_init(const struct device *dev). Initialization functions preconfigure any data, like resetting timers and position for hold-taps and tap-dances. All initialization functions return 0; once complete.

API Structure

Comes in the form static const struct behavior_driver_api <behavior_name>_driver_api). Common items to include in the API Structure are:

  • .binding_pressed: Used for behaviors that invoke an action on its keybind press. Set .binding_pressed equal to the function typically named on_<behavior_name>_binding_pressed.
  • .binding_released: Same as above, except for activating on keybind release events. Set .binding_released equal to the function typically named on_<behavior_name>_binding_released.
  • .locality: Defined in <drivers/behavior.h>. Describes how the behavior affects parts of a split keyboard.
    • BEHAVIOR_LOCALITY_CENTRAL: Behavior only affects the central half, which is the case for most keymap-related behavior.
    • BEHAVIOR_LOCALITY_EVENT_SOURCE: Behavior affects only the central or peripheral half depending on which side invoked the behavior binding, such as reset behaviors.
    • BEHAVIOR_LOCALITY_GLOBAL: Behavior affects the entire keyboard, such as external power and lighting-related behaviors that need to be synchronized across halves.
      note

      For unibody keyboards, all locality values perform the same as BEHAVIOR_LOCALITY_GLOBAL.

Data Pointers (Optional)

The data struct stores additional data required for each new instance of the behavior. Regardless of the instance number, n, behavior_<behavior_name>_data_##n is typically initialized as an empty struct. The data respective to each instance of the behavior can be accessed in functions like on_<behavior_name>_binding_pressed(struct zmk_behavior_binding *binding, struct zmk_behavior_binding_event event) by extracting the behavior device from the keybind like so:

const struct device *dev = zmk_behavior_get_binding(binding->behavior_dev);
struct behavior_<behavior_name>_data *data = dev->data;

The variables stored inside the data struct, data, can be then modified as necessary.

The fourth cell of BEHAVIOR_DT_INST_DEFINE can be set to NULL instead if instance-specific data is not required.

Configuration Pointers (Optional)

The configuration struct stores the properties declared from the behavior's .yaml for each new instance of the behavior. As seen in the #define KP_INST(n) of the hold-tap example, the configuration struct, behavior_<behavior_name>_config_##n, for each instance number, n, can be initialized using the Zephyr Devicetree Instance-based APIs, which extract the values from the properties of each instance of the devicetree binding from a user's keymap or predefined use-case .dtsi files stored in app/dts/behaviors/. We illustrate this further by comparing the #define KP_INST(n) from the hold-tap driver and the properties of the hold-tap devicetree binding.

The fifth cell of BEHAVIOR_DT_INST_DEFINE can be set to NULL instead if instance-specific configurations are not required.

warning

Remember that .c files should be formatted according to clang-format to ensure that checks run smoothly once the pull request is submitted.

Updating app/CmakeLists.txt to include the new driver

Most behavior drivers' are invoked according to the central half's locality, and are therefore stored after the line if ((NOT CONFIG_ZMK_SPLIT) OR CONFIG_ZMK_SPLIT_ROLE_CENTRAL) in the form, target_sources(app PRIVATE src/behaviors/<behavior_name>.c), as shown below.

app/CmakeLists.txt
if ((NOT CONFIG_ZMK_SPLIT) OR CONFIG_ZMK_SPLIT_ROLE_CENTRAL)
target_sources(app PRIVATE src/behaviors/behavior_key_press.c)
target_sources(app PRIVATE src/behaviors/behavior_hold_tap.c)
target_sources(app PRIVATE src/behaviors/behavior_sticky_key.c)
target_sources(app PRIVATE src/behaviors/behavior_caps_word.c)
target_sources(app PRIVATE src/behaviors/behavior_key_repeat.c)
target_sources(app PRIVATE src/behaviors/behavior_momentary_layer.c)
target_sources(app PRIVATE src/behaviors/behavior_mod_morph.c)
target_sources(app PRIVATE src/behaviors/behavior_outputs.c)
target_sources(app PRIVATE src/behaviors/behavior_tap_dance.c)
target_sources(app PRIVATE src/behaviors/behavior_toggle_layer.c)
target_sources(app PRIVATE src/behaviors/behavior_to_layer.c)
target_sources(app PRIVATE src/behaviors/behavior_transparent.c)
target_sources(app PRIVATE src/behaviors/behavior_none.c)
target_sources(app PRIVATE src/behaviors/behavior_sensor_rotate_key_press.c)
target_sources(app PRIVATE src/combo.c)
target_sources(app PRIVATE src/conditional_layer.c)
target_sources(app PRIVATE src/keymap.c)
endif()

For behaviors that do not require central locality, the following options for updating app/CmakeLists.txt also exist:

  • Behavior applies to unibody, or central or peripheral half of keyboard: place target_sources(app PRIVATE <behavior_name>.c) line before if ((NOT CONFIG_ZMK_SPLIT) OR CONFIG_ZMK_SPLIT_ROLE_CENTRAL)
  • Behavior applies to only central half of split keyboard: place target_sources(app PRIVATE <behavior_name>.c) after if (CONFIG_ZMK_SPLIT AND CONFIG_ZMK_SPLIT_ROLE_CENTRAL)
  • Behavior applies to only peripheral half of split keyboard: place target_sources(app PRIVATE <behavior_name>.c) after if (CONFIG_ZMK_SPLIT AND (NOT CONFIG_ZMK_SPLIT_ROLE_CENTRAL))
  • Behavior requires certain condition in a keyboard's .conf file to be met: use target_sources_ifdef(CONFIG_<Configuration Requirement> app PRIVATE <behavior_name>.c) instead of target_sources(<behavior_name>.c)

Defining common use-cases for the behavior (.dtsi) (Optional)

.dtsi files, found in the directory app/dts/behaviors/, are only necessary for behaviors with more common use-cases. A common example is the mod-tap (&mt), which is a predefined type of hold-tap that takes a modifier key as the hold parameter and another key as the tap parameter.

For the purpose of this section, we will discuss the structure of app/dts/behaviors/gresc.dtsi below.

app/dts/behaviors/gresc.dtsi
/*
* Copyright (c) 2020 The ZMK Contributors
*
* SPDX-License-Identifier: MIT
*/

#include <dt-bindings/zmk/keys.h>

/ {
behaviors {
/omit-if-no-ref/ gresc: grave_escape {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp ESC>, <&kp GRAVE>;
mods = <(MOD_LGUI|MOD_LSFT|MOD_RGUI|MOD_RSFT)>;
};
};
};

The format of a behavior's .dtsi file is identical to declaring an instance of the behavior in a user's keymap. The only major difference is that the value /omit-if-no-ref/ should be placed adjacent to the label and name of the behavior, as seen in line 11 of the gresc example.

warning

If your behavior has its locality property set to anything other than BEHAVIOR_LOCALITY_CENTRAL, then the name of the node must be at most 8 characters long, or it will fail to be invoked on the peripheral half of a split keyboard.

In the above example, grave_escape is too long, so it would need to be shortened, e.g.

// Behavior can be invoked on peripherals, so name must be <= 8 characters.
/omit-if-no-ref/ gresc: gresc { ... };

After creating the .dtsi from above, update app/dts/behaviors.dtsi to include your newly predefined behavior instance, making it accessible by the devicetree.

app/dts/behaviors.dtsi
#include <behaviors/key_press.dtsi>
#include <behaviors/transparent.dtsi>
#include <behaviors/none.dtsi>
#include <behaviors/mod_tap.dtsi>
#include <behaviors/layer_tap.dtsi>
#include <behaviors/gresc.dtsi>
#include <behaviors/sticky_key.dtsi>
#include <behaviors/momentary_layer.dtsi>
#include <behaviors/toggle_layer.dtsi>
#include <behaviors/to_layer.dtsi>
#include <behaviors/reset.dtsi>
#include <behaviors/sensor_rotate_key_press.dtsi>
#include <behaviors/rgb_underglow.dtsi>
#include <behaviors/bluetooth.dtsi>
#include <behaviors/ext_power.dtsi>
#include <behaviors/outputs.dtsi>
#include <behaviors/caps_word.dtsi>
#include <behaviors/key_repeat.dtsi>
#include <behaviors/backlight.dtsi>
#include <behaviors/macros.dtsi>
#include <behaviors/new_behavior_instance.dtsi>

Testing changes locally

Create a new folder in app/tests/ to develop virtual test sets for all common use cases of the behavior. Behaviors should be tested thoroughly on both virtual testing environments using west test and real hardware.

note

Zephyr currently does not support logging over Bluetooth, so any use of the serial monitor for hardware testing must be done over hardware UART or USB virtual UART.

info
  • See Tests for more information on how to create virtual test sets.
  • For hardware-based testing, see USB Logging.

Documenting behavior functionality

Consider the following prompts when writing documentation for new behaviors:

  • What does it do? Describe some general use-cases for the behavior.
  • Which properties included in the devicetree binding should be configured manually by the user? What do they do, and if applicable, what are their default values?
  • What does an example implementation in a keymap look like? Include a code-snippet of the example implementation in the keymap file's behaviors node.
  • How does the behavior perform in edge cases? For example, tap-dances invoke the last binding in its list of bindings once the maximum number of keypresses has been reached.

Consider also including visual aids alongside written documentation if it adds clarity.

info

See Documentation for more information on writing, testing, and formatting ZMK documentation.

Submitting a pull request

Once the above sections are complete, the behavior is almost ready to submit as a pull request. New devicetree bindings, new drivers, and predefined use-cases of the new behavior must contain the appropriate copyright headers, which can be copied and pasted from the tabs below.

# Copyright (c) XXXX The ZMK Contributors
# SPDX-License-Identifier: MIT
warning

Remember to change the copyright year (XXXX) to the current year when adding the copyright headers to your newly created files.

While you wait for your PR to become approved and merged into the main repository, please stay up to date for any code reviews and check in with users testing your new behavior. For more detailed information on contributing to ZMK, it is recommended to thoroughly review the documentation for contributors.