Skip to content

feat(usb-hid): queue and replay HID reports across USB not-ready windows#3384

Open
akira-toriyama wants to merge 1 commit into
zmkfirmware:mainfrom
akira-toriyama:feat/usb-hid-replay-on-ready
Open

feat(usb-hid): queue and replay HID reports across USB not-ready windows#3384
akira-toriyama wants to merge 1 commit into
zmkfirmware:mainfrom
akira-toriyama:feat/usb-hid-replay-on-ready

Conversation

@akira-toriyama

@akira-toriyama akira-toriyama commented Jun 9, 2026

Copy link
Copy Markdown

What

Add CONFIG_ZMK_USB_HID_REPLAY_ON_READY (default n). When enabled,
zmk_usb_hid_send_report enqueues HID reports issued during any USB
not-ready state (SUSPEND, ERROR, RESET, DISCONNECTED,
UNKNOWN, CONNECTED) into a small static ring buffer, and a
listener on zmk_usb_conn_state_changed schedules a delayable work
item to flush the queue shortly after USB transitions back to a ready
state.

Why

zmk_usb_hid_send_report currently drops every report issued while
the USB endpoint is not ready:

  • USB_DC_SUSPEND → returns usb_wakeup_request() (the report itself
    is discarded).
  • USB_DC_DISCONNECTED / RESET / UNKNOWN / ERROR → returns -ENODEV.
  • USB_DC_CONNECTED → falls into the default branch, where
    hid_int_ep_write is attempted on a not-yet-configured bus and
    fails silently.

The host HID stack additionally drops the first input report from a
freshly bound interface — a binding-race quirk observed on macOS.
Together these surface as:

  • "First key after dongle replug is ignored."
  • "First key on host sleep resume is lost or needs an extra press" —
    intermittent, because the user's press lands on a transient USB
    state (SUSPEND/RESET/CONNECTED) on the resume path, and macOS
    re-enumerates with one or more extra bus resets on deep wake.

This is especially visible on USB-dongle keyboard setups where a BLE
central forwards HID over USB — the host-side resume latency is on
the order of hundreds of ms and traverses several short-lived USB
device-controller states, so the user's first keystrokes fall inside
that window.

Related: #2686 (the BLE deep-sleep variant of the same symptom class —
out of scope here; a peripheral powered off in deep sleep consumes its
wake keypress at the hardware level before firmware runs).

How

Every not-ready case in zmk_usb_hid_send_report enqueues the report
bytes before returning its existing error. A new ZMK listener on
zmk_usb_conn_state_changed schedules a delayable work item when USB
reaches a ready state; the flush worker drains the ring buffer via
hid_int_ep_write. The configurable flush delay defaults to 100 ms,
which experimentally is enough on macOS to let the host finish HID
interface binding before the resend.

The flush path is hardened against deep-wake re-enumeration hazards
found during on-hardware testing:

  • Advisory semaphore take in the flush (mirroring the normal send
    path, which ignores its take result): a bus reset aborts an armed IN
    transfer without raising the write-complete callback
    (usb_dc_nrfx EP_ABORTED is log-only), stranding the semaphore at
    0; blocking on it would abandon the queue for the whole wake cycle.
  • Retry on a short timer while the bus is active when a flush
    write fails (e.g. -EAGAIN mid-enumeration), instead of waiting for
    a status-change event that may never come.
  • k_work_reschedule in the listener: k_work_schedule on an
    already-pending work keeps the old deadline, so a
    RESUME → RESET → CONFIGURED cluster would otherwise fire the flush
    ~0 ms after CONFIGURED — inside the host binding race the delay
    exists to avoid. Suspend entry (which still maps to a HID-ready conn
    state) no longer schedules a pointless flush.
  • Ordering: HID reports are absolute state snapshots, so a live
    release racing past its queued press would leave that key logically
    held on the host. While the queue is non-empty, ready-path reports
    append and flush in order.
  • Queue cleared on USB_DC_DISCONNECTED: replaying hours-old
    keystrokes on replug is phantom input, not recovery.

Queue sizing is configurable so embedded targets can trade RAM for
robustness:

  • CONFIG_ZMK_USB_HID_REPLAY_QUEUE_DEPTH — default 8, range 1..64
  • CONFIG_ZMK_USB_HID_REPLAY_REPORT_MAX_LEN — default 16 B, range 8..64
  • CONFIG_ZMK_USB_HID_REPLAY_FLUSH_DELAY_MS — default 100 ms, range 0..1000

Overflow drops the oldest entry. Oversized reports are dropped at
enqueue with a LOG_WRN. Default-off so the static memory cost
(depth × max_len + one mutex + one delayable work item) is only
paid by firmwares that opt in.

Testing

Tested in production on a dongle-based BLE split setup (XIAO BLE
central + two assimilator-bt peripherals forwarding HID over USB)
on macOS. With the option enabled:

  • Dongle physical unplug → replug → first key arrives immediately.
  • PC sleep → resume → no extra keypress needed beyond the wake key
    that macOS itself consumes by design (its HID pipeline cancels
    key-downs within 500 ms of wake and while the display is off — that
    part is host behaviour common to all keyboards, not fixable in
    firmware).
  • No stuck keys, phantom repeats, or latency regressions in normal
    typing, including immediately after resume.

Default-off path is byte-identical (#if-guarded), verified by
building the same target with and without the option enabled.

Downstream use: akira-toriyama/canon
runs this change as an out-of-tree patch (patches/zmk/usb-hid-prime-on-ready.patch)
pending this PR.

PR check-list

  • Branch has a clean commit history
  • Additional tests are included, if changing behaviors/core code that is testable. (no zmk-side test harness for USB HID lifecycle; happy to add one if pointed at an existing pattern)
  • Proper Copyright + License headers added to applicable files
  • Pre-commit used to check formatting
  • Includes any necessary documentation changes. (can add a docs mention if maintainers prefer)

@akira-toriyama
akira-toriyama requested a review from a team as a code owner June 9, 2026 07:24
akira-toriyama added a commit to akira-toriyama/canon that referenced this pull request Jun 9, 2026
…#46)

両 patch を zmkfirmware/zmk へ Kconfig gate (default n) 付きで PR 済:

- security-changed-auto-unpair → zmkfirmware/zmk#3385
- usb-hid-prime-on-ready → zmkfirmware/zmk#3384

merge され次第 patches/zmk/ から畳む方針。dongle-roadmap.md と
patches/zmk/README.md の該当節を「提案準備中」から「PR 中」状態に
更新する。

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@akira-toriyama
akira-toriyama force-pushed the feat/usb-hid-replay-on-ready branch from 0ea1f07 to c58e5bf Compare June 11, 2026 01:38
akira-toriyama added a commit to akira-toriyama/canon that referenced this pull request Jun 11, 2026
#43 で導入した usb-hid-prime-on-ready.patch は USB_DC_SUSPEND のみで
report を enqueue していたが、PC スリープ復帰時の USB state 遷移は
SUSPEND → RESET → CONNECTED → CONFIGURED と段階的なため、その途中の
RESET / DISCONNECTED / UNKNOWN / ERROR 状態の瞬間に来た打鍵は
-ENODEV で破棄されていた。

これがスリープ復帰で「たまに 1 キー要る」症状の正体。

USB_DC_SUSPEND と同様に not-ready 系の全 case でも enqueue するよう
拡張。queue は depth 8 / drop-oldest なので memory は bounded で
DISCONNECTED 永続化のリスク無し。dongle 構成ではケーブル抜き =
電源喪失 = code 走らないので副作用なし。

upstream PR (zmkfirmware/zmk#3384) にも同じ修正を反映予定。

実機検証: スリープ復帰サイクル数回で 1 キー消失が再発しないこと、
dongle 抜き差し / 長時間無操作の既存ケースも regression 無しを確認。

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@akira-toriyama
akira-toriyama force-pushed the feat/usb-hid-replay-on-ready branch from c58e5bf to 880e6ba Compare June 12, 2026 02:04
akira-toriyama added a commit to akira-toriyama/canon that referenced this pull request Jun 12, 2026
0ベース調査で発見した flush 側の欠陥 4 件を修正する。
症状「スリープ復帰でたまに 1 キー必要」の firmware 側残り要因。

1. semaphore 枯渇で flush が諦める (本命):
   bus reset は armed 済み IN 転送を callback 無しで破棄する
   (usb_dc_nrfx.c の EP_ABORTED は log のみ)。in_ready_cb が呼ばれず
   hid_sem が 0 のまま残り、flush の k_sem_take(50ms) が timeout して
   queue を放置していた。通常送信パスと同じく take 結果を advisory に
   し、write 失敗時のみ took==0 の場合に give back する。

2. flush リトライ追加:
   write 失敗 (mid-enumeration の -EAGAIN 等) 時、bus が active なら
   100ms 後に再 flush。今までは次の状態変化イベント任せだった。

3. k_work_reschedule 化:
   k_work_schedule は pending 中の work の deadline を維持するため、
   RESUME -> RESET -> CONFIGURED が 100ms 内に連続すると flush が
   CONFIGURED 直後 ~0ms で発火し、host HID binding race の猶予が
   無効化されていた。reschedule で最後の遷移から猶予を取り直す。
   suspend 突入時の無駄な flush 予約も廃止。

4. 順序保証 + stale queue クリア:
   resume 後の生 report が queue 内の古い report を追い越すと
   press/release が逆転しスタックキー化するため、queue 非空時は
   append して flush に順序配送させる。物理切断 (DISCONNECTED) では
   queue を破棄し、replug 時の幽霊入力を防ぐ。

実機検証:
- 検証付き flash (post-flash USB enumeration 確認) で導入
- キーボード / トラックボールとも正常動作を確認
- 本 patch から再ビルドしたバイナリが実機動作中のものと
  sha1 一致 (b5f48517) であることを確認
- sleep-resume の残症状は継続観察中

upstream PR (zmkfirmware/zmk#3384) にも同修正を反映予定。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
akira-toriyama added a commit to akira-toriyama/canon that referenced this pull request Jun 13, 2026
* 🐛 fix(dongle): sleep 復帰の USB_DC_CONNECTED でも HID report を queue 化

#48 で SUSPEND / ERROR / RESET / DISCONNECTED / UNKNOWN を queue 化
したが、スリープ復帰時の状態遷移
SUSPEND → RESET → CONNECTED → CONFIGURED の
USB_DC_CONNECTED の瞬間に打鍵が来た場合、これは default 分岐に落ちて
hid_int_ep_write が試みられる。endpoint は connected だが未 configured
なので silent fail し、report が消失する。

これがスリープ復帰で「たまに 1 キー要る」 intermittent 症状の残り。

USB_DC_CONNECTED も not-ready 系の enqueue 対象に追加。default 分岐は
CONFIGURED / RESUME / CLEAR_HALT / SOF (= 実際に送れる状態) だけが
通る形に整理される。

upstream PR (zmkfirmware/zmk#3384) にも同じ修正を反映予定。

実機検証: スリープ復帰サイクル数回で 1 キー消失再発無し、
dongle 抜き差し / 長時間無操作も regression 無しを確認。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(dongle): HID replay queue の flush を deep-wake 経路に対して堅牢化

0ベース調査で発見した flush 側の欠陥 4 件を修正する。
症状「スリープ復帰でたまに 1 キー必要」の firmware 側残り要因。

1. semaphore 枯渇で flush が諦める (本命):
   bus reset は armed 済み IN 転送を callback 無しで破棄する
   (usb_dc_nrfx.c の EP_ABORTED は log のみ)。in_ready_cb が呼ばれず
   hid_sem が 0 のまま残り、flush の k_sem_take(50ms) が timeout して
   queue を放置していた。通常送信パスと同じく take 結果を advisory に
   し、write 失敗時のみ took==0 の場合に give back する。

2. flush リトライ追加:
   write 失敗 (mid-enumeration の -EAGAIN 等) 時、bus が active なら
   100ms 後に再 flush。今までは次の状態変化イベント任せだった。

3. k_work_reschedule 化:
   k_work_schedule は pending 中の work の deadline を維持するため、
   RESUME -> RESET -> CONFIGURED が 100ms 内に連続すると flush が
   CONFIGURED 直後 ~0ms で発火し、host HID binding race の猶予が
   無効化されていた。reschedule で最後の遷移から猶予を取り直す。
   suspend 突入時の無駄な flush 予約も廃止。

4. 順序保証 + stale queue クリア:
   resume 後の生 report が queue 内の古い report を追い越すと
   press/release が逆転しスタックキー化するため、queue 非空時は
   append して flush に順序配送させる。物理切断 (DISCONNECTED) では
   queue を破棄し、replug 時の幽霊入力を防ぐ。

実機検証:
- 検証付き flash (post-flash USB enumeration 確認) で導入
- キーボード / トラックボールとも正常動作を確認
- 本 patch から再ビルドしたバイナリが実機動作中のものと
  sha1 一致 (b5f48517) であることを確認
- sleep-resume の残症状は継続観察中

upstream PR (zmkfirmware/zmk#3384) にも同修正を反映予定。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(imprint): 半体の deep sleep を全廃し離席後の 1 打目消失を解消

「3 時間離席後、左右それぞれ 1 打目が反応しない」症状の対策。

真因: 半体は Cyboard shield 既定 (ZMK_SLEEP=y / IDLE_SLEEP_TIMEOUT=
900000) により 15 分無操作で sys_poweroff = 完全電源断していた。
電源断からの 1 打目は GPIO wake トリガーに物理的に消費される
(firmware 非稼働中は記録不能) ため、いかなる firmware 側 buffering
でも救えない。左右が独立に 1 打ずつ食われるのはこのため。

USB 有線運用では USB 給電が sleep を抑止するため発生せず、dongle 化
(電池駆動化) で初めて顕在化した。ZMK_SLEEP=n で有線時代と同じ挙動に
戻る。

電池影響: 常時 connected-idle (~50µA) でも待機消費は月 2〜4% 程度で
許容範囲 (#41 の BLE tight 化のような radio wake 30 倍とは桁違い)。

備考: #41 では「ZMK_SLEEP=n だと peripheral が起動しなくなる」と判断
して見送ったが、当時は flash 成否の検証手段が無く、flash 破損
(壊れた app イメージで起動不能) の誤診だった。検証付き flash
(post-flash 再マウント監視) で =n を再テストし、左右ともキーボード・
トラックボール正常動作を確認した。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add CONFIG_ZMK_USB_HID_REPLAY_ON_READY (default n) which buffers HID
reports that arrive while the USB endpoint is in any not-ready state
(USB_DC_SUSPEND, ERROR, RESET, DISCONNECTED, UNKNOWN, CONNECTED) into
a small ring buffer, then flushes them shortly after USB transitions
back to USB_DC_CONFIGURED / USB_DC_RESUME.

Why
---

zmk_usb_hid_send_report currently drops every report issued while the
USB endpoint is not ready: USB_DC_SUSPEND returns usb_wakeup_request()
(the report itself is discarded), USB_DC_DISCONNECTED / RESET /
UNKNOWN / ERROR return -ENODEV, and USB_DC_CONNECTED falls into the
default branch where hid_int_ep_write is attempted on a bus that is
not yet configured (silent failure). The host HID stack additionally
drops the first input report from a freshly bound interface (a
binding-race quirk observed on macOS). Together these surface as:

- "First key after dongle replug is ignored"
- "First key on host sleep resume is lost or needs an extra press" -
  intermittent, because the user's press lands on a transient USB
  state on the resume path (SUSPEND / RESET / CONNECTED) and macOS
  re-enumerates with one or more extra bus resets on deep wake.

This is particularly visible on USB-dongle keyboard setups where a
BLE central forwards HID over USB; the host-side resume latency is
on the order of hundreds of ms and traverses several short-lived
USB device-controller states, so the user's first keystrokes fall
inside that window.

What
----

Every not-ready case in zmk_usb_hid_send_report now enqueues the
report bytes before returning its existing error. A listener on
zmk_usb_conn_state_changed schedules a delayable work item that
flushes the queue once the host is HID-ready again; the configurable
flush delay (default 100 ms) gives the host time to finish HID
interface binding before the firmware resends.

The flush path is hardened against deep-wake re-enumeration hazards
found while testing on real hardware:

- The semaphore take in the flush is advisory (mirroring the normal
  send path, which ignores its take result): a bus reset aborts an
  armed IN transfer without raising the write-complete callback
  (usb_dc_nrfx EP_ABORTED is log-only), stranding the semaphore at 0.
  Blocking on it would abandon the queue for the whole wake cycle.
- A failed flush write retries on a short timer while the bus is
  active, instead of waiting for a status-change event that may never
  come (e.g. -EAGAIN mid-enumeration).
- The listener uses k_work_reschedule, not k_work_schedule: scheduling
  an already-pending work keeps the OLD deadline, so a RESUME -> RESET
  -> CONFIGURED cluster would otherwise fire the flush ~0ms after
  CONFIGURED, inside the very host binding race the delay avoids. The
  listener also ignores suspend entry (which still maps to a HID-ready
  conn state) instead of scheduling a pointless flush.
- Live reports never overtake queued ones: HID reports are absolute
  state snapshots, so a live release racing past its queued press
  would leave that key logically held on the host. While the queue is
  non-empty, ready-path reports append and flush in order.
- The queue is cleared on USB_DC_DISCONNECTED: replaying hours-old
  keystrokes on replug is phantom input, not recovery.

The queue is sized at build time:
- CONFIG_ZMK_USB_HID_REPLAY_QUEUE_DEPTH (default 8, range 1..64)
- CONFIG_ZMK_USB_HID_REPLAY_REPORT_MAX_LEN (default 16 B, range 8..64)
- CONFIG_ZMK_USB_HID_REPLAY_FLUSH_DELAY_MS (default 100 ms, range 0..1000)

Overflow drops the oldest entry. Oversized reports are dropped at
enqueue with a LOG_WRN. Static RAM cost with defaults is queue depth
* max length + a mutex + a delayable work item.

Default is `n` to preserve existing behaviour; firmwares hit by the
above symptoms can opt in.

Related: zmkfirmware#2686

Signed-off-by: Akira Toriyama <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant