{"id":51347267,"url":"https://github.com/damex/zmk-feature-split-esb","last_synced_at":"2026-07-02T12:06:04.831Z","repository":{"id":360282764,"uuid":"1249438744","full_name":"damex/zmk-feature-split-esb","owner":"damex","description":"ZMK split transport over Nordic ESB (2.4 GHz)","archived":false,"fork":false,"pushed_at":"2026-06-20T13:36:17.000Z","size":205,"stargazers_count":3,"open_issues_count":2,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-06-20T14:33:59.847Z","etag":null,"topics":["esb","split-esb","zmk","zmk-module"],"latest_commit_sha":null,"homepage":"","language":"C","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/damex.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-25T17:45:24.000Z","updated_at":"2026-06-18T16:38:05.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/damex/zmk-feature-split-esb","commit_stats":null,"previous_names":["damex/zmk-feature-esb","damex/zmk-feature-split-esb"],"tags_count":22,"template":false,"template_full_name":null,"purl":"pkg:github/damex/zmk-feature-split-esb","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/damex%2Fzmk-feature-split-esb","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/damex%2Fzmk-feature-split-esb/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/damex%2Fzmk-feature-split-esb/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/damex%2Fzmk-feature-split-esb/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/damex","download_url":"https://codeload.github.com/damex/zmk-feature-split-esb/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/damex%2Fzmk-feature-split-esb/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35046030,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-02T02:00:06.368Z","response_time":173,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["esb","split-esb","zmk","zmk-module"],"created_at":"2026-07-02T12:06:04.253Z","updated_at":"2026-07-02T12:06:04.817Z","avatar_url":"https://github.com/damex.png","language":"C","funding_links":[],"categories":[],"sub_categories":[],"readme":"# zmk-feature-split-esb\n\nEnhanced ShockBurst (2.4 GHz) split transport for ZMK. One or more peripherals, one central.\nPacket-native: split messages map to ESB packets (a report's input events\ncoalesce into one, each packed to a compact on-air form), carried over a\nlock-free SPSC RX path.\nESB hardware ACK + retransmit + CRC handle reliability.\n\n## Install\n\nAdd it to your `config/west.yml`. `import: true` pulls the deps a vanilla ZMK\nworkspace lacks (`zmk` and Zephyr come from your own manifest):\n```yaml\n  remotes:\n    - name: damex\n      url-base: https://github.com/damex\n  projects:\n    - name: zmk-feature-split-esb\n      remote: damex\n      revision: v0.3.6\n      import: true\n```\nThen update and apply the Kconfig fixes `sdk-nrf` needs on ZMK's Zephyr:\n```\nwest update\nwest patch -sm zmk-feature-split-esb apply\n```\nFor a local checkout, build with `-DZMK_EXTRA_MODULES=\u003cpath\u003e/zmk-feature-split-esb`\ninstead. Your workspace must then already provide `sdk-nrf` + `nrfxlib` with the\npatches applied.\n\n## Configure\n\nBoard/shield conf, select ESB and drop the other transports:\n```conf\nCONFIG_ZMK_SPLIT=y\nCONFIG_ZMK_SPLIT_BLE=n\nCONFIG_ZMK_SPLIT_WIRED=n\nCONFIG_ZMK_SPLIT_ESB=y\nCONFIG_ESB_MAX_PAYLOAD_LENGTH=48\n```\nCentral also sets `CONFIG_ZMK_SPLIT_ROLE_CENTRAL=y`. Peripheral leaves it unset.\n\nSet `CONFIG_ESB_MAX_PAYLOAD_LENGTH` to at least `ZMK_SPLIT_ESB_MAX_PAYLOAD` (48).\nModule defaults it, but sdk-nrf default (32) can win on Kconfig parse order, so set\nit explicitly on every device. Build assert catches value too small for largest\nsplit message.\n\nDT side: shared dtsi every device includes, plus role-specific lines. Everything\nreports to the dongle (central). Two examples, by peripheral count and what each\nsources.\n\n### One peripheral: a mouse\n\nPointer motion: input events, routed by one `zmk,input-split` per pipe.\n\nShared (`esb_shared.dtsi`), every device:\n```dts\n/ {\n    esb_link {\n        compatible = \"zmk,split-esb\";\n        base-address = [E7 D3 9A 5C];\n        peripherals {\n            mouse: peripheral_mouse {\n                pipe = \u003c0\u003e;\n                prefix = \u003c0x4B\u003e;\n                weight = \u003c1\u003e;\n            };\n        };\n    };\n\n    split_inputs {\n        #address-cells = \u003c1\u003e;\n        #size-cells = \u003c0\u003e;\n        mouse_split: mouse_split@0 {\n            compatible = \"zmk,input-split\";\n            reg = \u003c0\u003e;\n        };\n    };\n\n    mouse_listener: mouse_listener {\n        compatible = \"zmk,input-listener\";\n        status = \"disabled\";\n        device = \u003c\u0026mouse_split\u003e;\n    };\n};\n```\nDongle (central): enable listener.\n```dts\n#include \"esb_shared.dtsi\"\n\u0026mouse_listener { status = \"okay\"; };\n```\nMouse (peripheral): claim pipe, bind sensor.\n```dts\n#include \"esb_shared.dtsi\"\n/ { chosen { zmk,esb-self = \u0026mouse; }; };\n\u0026mouse_split { device = \u003c\u0026your_sensor\u003e; };\n```\nTwo mice, same shape: second child, second `mouse_split@1` and listener. Each mouse\nbinds its own split, disables the others.\n\n### Two peripherals: a split keyboard\n\nBoth halves are peripherals, report to the dongle, never to each other. Key positions\nride the transport directly, no input-split. Left pipe 0, right pipe 1.\n\nShared (`esb_shared.dtsi`):\n```dts\n/ {\n    esb_link {\n        compatible = \"zmk,split-esb\";\n        base-address = [E7 D3 9A 5C];\n        peripherals {\n            left: peripheral_left {\n                pipe = \u003c0\u003e;\n                prefix = \u003c0x4B\u003e;\n                weight = \u003c1\u003e;\n            };\n            right: peripheral_right {\n                pipe = \u003c1\u003e;\n                prefix = \u003c0xC3\u003e;\n                weight = \u003c1\u003e;\n            };\n        };\n    };\n};\n```\nDongle (central): holds keymap and matrix transform spanning both halves, standard\nZMK split. Key positions deliver natively, no per-key listener.\n```dts\n#include \"esb_shared.dtsi\"\n```\nLeft half (peripheral): claim pipe 0, own kscan.\n```dts\n#include \"esb_shared.dtsi\"\n/ { chosen { zmk,esb-self = \u0026left; }; };\n```\nRight half (peripheral): claim pipe 1.\n```dts\n#include \"esb_shared.dtsi\"\n/ { chosen { zmk,esb-self = \u0026right; }; };\n```\nKeys deliver, but peripheral-count limit below applies: no global-behavior broadcast,\nno HID-indicator forwarding.\n\n| DT property | Value |\n|---|---|\n| `base-address` | 4-byte bytestring `[..]`, shared across pipes, vary bytes (all-same-byte syncs poorly) |\n| `address-length` | on-air address bytes 3/4/5, shorter trims airtime, weakens selectivity, all devices must match (default 5) |\n| `peripherals` | one child node per peripheral: `pipe`, `prefix` (1 byte), `weight`, `reply-queue-depth` (central reverse-channel backlog for this pipe, default 8) |\n| `hop-channels` | channel bytestring, each 0-100 (2400 + N MHz). 1 = fixed, 2+ = hopping set |\n| `hop-anchors` | unmaskable rendezvous set, a subset of hop-channels (default 76, 79, 82). Pick channels clear of local WiFi |\n| `hop-threshold` | graded loss before acting: central hop-vote sum, peripheral sweep streak; fully-lost window scores 4 (default 6) |\n| `hop-window-ms` | peripheral keepalive period while data flows (default 32) |\n| `rssi-floor-dbm` | central counts a served peripheral's motion weaker than this (dBm) as a degraded window (default -85) |\n| `idle-keepalive-ms` | peripheral idle keepalive period, also central hop-decision window (default 128) |\n| `peripheral-timeout-ms` | silence before the central releases a peripheral's held state (default 3000) |\n| `tx-power-dbm` | boot TX power in dBm, raise for range (default 0) |\n| `retransmit-count` | retransmits before drop (default 3) |\n| `retransmit-delay-us` | delay between retransmits (default 600) |\n| `use-fast-ramp-up` | shorter radio ramp-up, nRF52/nRF53, all peripherals must match the central |\n| `crc-bits` | CRC width 0/8/16, all peripherals must match the central (default 16) |\n| `bitrate-kbps` | radio bitrate 1000/2000, all peripherals must match the central (default 2000) |\n| `lossy-codes` | optional list of `\u003cINPUT_EV_* code\u003e` pairs sent without ACK |\n\nLossy-codes lists the input axes peripherals fire-and-forget. Reserve for\nhigh-rate, self-correcting axes (pointer motion). Non-input split events\n(key-position, sensor, battery) are always ACK'd. Every input event is ACK'd\nunless its (type, code) is listed here. Omitted = fully lossless link. Example\nfor a mouse:\n```dts\n#include \u003czephyr/dt-bindings/input/input-event-codes.h\u003e\n\u0026esb_link {\n    lossy-codes\n        = \u003cINPUT_EV_REL INPUT_REL_X\u003e\n        , \u003cINPUT_EV_REL INPUT_REL_Y\u003e;\n};\n```\n\nTunables (Kconfig, defaults shown):\n\n| Option | Default | Notes |\n|---|---|---|\n| `ZMK_SPLIT_ESB_MAX_PAYLOAD` | 48 | max on-air bytes (\u003e= largest split msg) |\n| `ZMK_SPLIT_ESB_RX_QUEUE_SIZE` | 16 | RX SPSC ring depth (power of 2) |\n| `ZMK_SPLIT_ESB_RX_THREAD_STACK_SIZE` | 1536 | RX dispatch thread stack (decode + input forward; behavior-bound events run on the system workqueue) |\n| `ZMK_SPLIT_ESB_RX_THREAD_PRIORITY` | 2 | RX dispatch thread priority |\n| `ZMK_SPLIT_ESB_EVENT_QUEUE_SIZE` | 16 | central queue for key/sensor/battery events bound for the system workqueue |\n| `ZMK_SPLIT_ESB_COMMAND_QUEUE_SIZE` | 8 | peripheral queue for inbound central commands |\n| `ZMK_SPLIT_ESB_PRIORITY` | 50 | transport registration priority |\n\n## Lost-event reconcile\n\nSplit events are deltas, the keepalive is the state: each peripheral keepalive\ncarries a snapshot of its live state (activity, pressed-position bitmap, battery\nlevel). Events are ACK'd, but the radio gives up after `retransmit-count` tries,\nso a delta can still die in a bad-RF moment. The central reconciles its view\nagainst every snapshot and replays what was lost.\n\nA stuck key heals within one keepalive period (`hop-window-ms` while typing,\n`idle-keepalive-ms` at idle). The live stream is healed too: an orphan release\n(lost press) drops before ZMK sees it, a repeated press synthesizes its lost\nrelease first. A changed battery level reconciles the same way. Keepalives run on\nsingle-channel links too. Positions 64 and above are not covered.\n\nA peripheral silent past `peripheral-timeout-ms` (sleep, dead battery, out of\nrange) gets its held keys and input-split buttons released, the connectionless\nequivalent of a disconnect. If it returns with a key still physically held, its\nfirst keepalive re-presses it.\n\n## Channel hopping\n\nList two or more channels in `hop-channels` and the link hops between them, stepping\noff a channel that degrades. The central drives the hop: it counts a served\nperipheral's window bad when motion goes missing or arrives weaker than\n`rssi-floor-dbm`, and a weighted vote across peripherals (`hop-threshold`) moves the\nwhole link to the next channel. A peripheral that loses the central sweeps the list\nto re-find it, sweeping faster the more its acked transmits retried. One channel is a\nfixed link, no hopping. Every peripheral must carry the central's list, so flash them\nas a set.\n\nA few channels (the anchors) are held unmaskable, the rendezvous set both ends meet on\nwhen the link is lost. `hop-anchors` picks them, default 76, 79, 82. The engine can never\nmask an anchor, so pick channels clear of local WiFi, otherwise the link rendezvouses on\ncontended spectrum. The rest of the pool carries data, AFH drops the channels that\nperform badly.\n\n## Load order\n\nEach step overrides the previous:\n\n1. **Boot defaults:** the DT props program the radio at startup.\n2. **Saved values:** any persisted in NVS apply on top (needs `CONFIG_SETTINGS`).\n3. **Live changes:** pushed at runtime via `settings_runtime_set`, no reflash.\n\nRuntime-overridable keys: `esb/tx_power`, `esb/retransmit_count`, `esb/retransmit_delay`.\n\n## Status API\n\nRead-only link state for display widgets and diagnostics, declared in\n`include/zmk_split_esb.h`:\n\n```c\n#include \u003czmk_split_esb.h\u003e\n\nstruct zmk_split_esb_status status;\nzmk_split_esb_get_status(\u0026status);\n```\n\n| Field | Meaning |\n|---|---|\n| `channel` | current RF channel |\n| `epoch` | hop generation |\n| `searching` | link degraded, hunting for the peer |\n| `rssi_dbm` | received signal: central reports worst sampled peripheral link, peripheral its central link |\n\n`rssi_dbm` reads 0 until the first packet arrives.\n\nPer-link signal on a multi-peripheral central: `zmk_split_esb_pipe_count()` plus\n`zmk_split_esb_pipe_rssi_dbm(pipe)`, indexed by ESB pipe. A peripheral has only\nindex 0.\n\n## Limitations\n\nPeripheral battery levels raise `zmk_peripheral_battery_state_changed` on the\ncentral, since ZMK's own handler sits behind `ZMK_SPLIT_BLE`, dead on an ESB-only\nbuild. Central `.conf` needs `CONFIG_ZMK_BATTERY_REPORTING=y` for the event to\nexist. Display widgets and indicators consume it as usual.\n\n`ZMK_SPLIT_CENTRAL_PERIPHERAL_COUNT` is 0 on an ESB-only central (ZMK derives it\nfrom BLE/wired counts). Events still deliver, but:\n\n- Commands to the peripheral need `BEHAVIOR_LOCALITY_EVENT_SOURCE`, source = its id.\n- No `BEHAVIOR_LOCALITY_GLOBAL` broadcast or HID-indicator forwarding (both index that count).\n\n## License\n\nThis module is MIT.\n\nWorkspace dependencies pulled by `west.yml`. Each keeps its own license:\n\n| Dependency | License |\n|---|---|\n| ZMK | MIT |\n| Zephyr | Apache-2.0 |\n| sdk-nrf | LicenseRef-Nordic-5-Clause |\n| nrfxlib | LicenseRef-Nordic-5-Clause (parts this module's builds touch: MPSL, softdevice_controller) |\n\nThe `LicenseRef-Nordic-5-Clause` parts restrict use to Nordic hardware.\n\nFor an ESB-only build the link symbols come from sdk-nrf.\nnrfxlib is still cloned into your workspace and gets linked if you also enable BLE.\n\nPatches under `zephyr/patches/nrf/` modify sdk-nrf files (Nordic-licensed) and\napply on the user side via `west patch`. They don't redistribute Nordic source.\n`patches.yml` lists upstream versions after which each patch can be dropped.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdamex%2Fzmk-feature-split-esb","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdamex%2Fzmk-feature-split-esb","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdamex%2Fzmk-feature-split-esb/lists"}