Skip to content

Add MQTT command to trigger stored keyboard macros - #1518

Open
markfrancisonly wants to merge 1 commit into
jetkvm:devfrom
markfrancisonly:feat/mqtt-macro
Open

Add MQTT command to trigger stored keyboard macros#1518
markfrancisonly wants to merge 1 commit into
jetkvm:devfrom
markfrancisonly:feat/mqtt-macro

Conversation

@markfrancisonly

Copy link
Copy Markdown

Closes #1517

Summary

  • Adds a keyboard_macro/set MQTT command that triggers a stored keyboard macro
    by ID (or case-insensitive name), gated on the existing EnableActions flag.
    The macro runs through the same session-independent rpcExecuteKeyboardMacro
    path the browser uses, so automation (Home Assistant, scripts) can fire a
    stored macro while a WebRTC session stays connected. Home Assistant discovery
    adds one button per stored macro.

Checklist

  • Ran make test_e2e locally and passed (see below)
  • Linked to issue(s) above by issue number (e.g. Closes #<issue-number>)
  • One problem per PR (no unrelated changes)
  • Lints pass; CI green
  • Tricky parts are commented in code

E2E Testing Results: make check (go vet + go test ./...) passed, and both
build_dev firmware builds (baseline + release) succeeded. make test_e2e DEVICE_IP=<ip> ran the full Playwright suite against a physical device:

  • ui project: 14/14 passed.
  • remote-agent project: skipped — these require a controlled host wired to
    the KVM (HDMI + USB) running the remote agent (JETKVM_REMOTE_HOST), which I
    don't have set up.
  • OTA projects: failed only on environment gaps unrelated to this change
    no HDMI video source for the video-dimension check, and OTA release
    availability. Notably, the ota-upgrade-to-signed test installed this
    branch's firmware, rebooted, and reconnected successfully before the
    video-signal step.
  • manually verified keyboard_macro/set end-to-end against a device (macro
    types into the attached host over MQTT while a browser session stays
    connected). This is a backend-only MQTT change and doesn't touch the
    HID-injection/video/OTA paths those remaining tests cover.

What changed
  • New package internal/keyboard (keymap.go): the key-name→HID-usage and
    modifier-name→mask tables, ported from ui/src/keyboardMappings.ts so the
    device agrees byte-for-byte with the names the web UI stores into macros.
    HIDSteps([]MacroStep) converts a stored macro's steps into the wire-level
    hidrpc.KeyboardMacroSteps, mirroring the web UI's executeMacroRemote
    (ui/src/hooks/useKeyboard.ts): each step becomes a press report held briefly
    plus an all-zero release report carrying the step's delay. The explicit
    release is required — rpcDoExecuteKeyboardMacro plays reports as-is and only
    auto-releases on cancellation, so without it keys stay held on the host.
    Macros are layout-independent because they are authored as key names, not
    characters.
  • mqtt_commands.gohandleKeyboardMacroCommand, registered under
    keyboard_macro/set, following the existing handleJigglerCommand shape
    (actionsAllowed() gate → trim → log → act). It is subscribed at QoS 0
    (unlike the other commands): the client uses a persistent session
    (CleanSession(false)), so a QoS 1 subscription would make the broker queue
    commands published while the device is offline and replay them on reconnect —
    a macro must never be replayed. For the same reason retained messages are
    dropped. Execution is dispatched to a goroutine because paho serializes
    callbacks and a macro can sleep for seconds.
  • mqtt_discovery.go — one Home Assistant button per stored macro,
    published only when actions are enabled. Buttons are slot-indexed
    (macro_1 .. macro_<MaxMacrosPerDevice>): each pass publishes occupied
    slots and clears empty ones, so retained configs for deleted or renamed macros
    are always removed — including ones published before a reboot — without
    tracking any state. payload_press carries the macro ID, so a button
    always resolves against current config. setKeyboardMacros republishes
    discovery so macro edits appear in HA immediately, and teardown sweeps the
    slots.
  • jsonrpc.gorpcExecuteKeyboardMacro was only ever called from the
    single WebRTC session's HID queue, so callers were inherently serial. MQTT
    adds genuinely concurrent callers (a goroutine per command, and a browser
    paste can run at the same time), so it now cancels any running macro and
    serializes execution — overlapping macros run one after another instead of
    interleaving reports on the shared gadget. This also hardens the pre-existing
    browser-paste-vs-paste path.
How it works (session-independent HID path)
MQTT publish ─► handleKeyboardMacroCommand      (mqtt_commands.go)
                  │ actionsAllowed() gate, retained-message drop
                  │ findKeyboardMacro(payload)  -> *KeyboardMacro
                  │ keyboard.HIDSteps(steps)    -> []hidrpc.KeyboardMacroStep
                  ▼   (own goroutine)
             rpcExecuteKeyboardMacro            (jsonrpc.go, existing)
                  ▼
             rpcKeyboardReport ─► gadget.KeyboardReport  (global USB gadget)

rpcExecuteKeyboardMacro nil-checks currentSession everywhere, so no WebRTC
session is required and a connected browser session is not evicted.

How to test

With MQTT configured, EnableActions on, and a macro stored with ID
login-root (base topic defaults to jetkvm/<device-id>):

mosquitto_pub -h mqtt.lan -u ha -P 's3cret' \
  -t 'jetkvm/mydevice123/keyboard_macro/set' -m 'login-root'

Keep the web UI open in a browser while doing this — the macro types into the
attached host and the browser session is not kicked. You can also publish the
macro's name (case-insensitive). In Home Assistant, the device shows a
"Macro: <name>" button per stored macro that publishes the same command.

Negative checks: unknown macro → warning logged, nothing typed; EnableActions
off → command rejected and buttons removed; retained publish → ignored.

Security considerations
  • Selector, not injector. The payload selects a macro that already exists in
    config.KeyboardMacros; it cannot define arbitrary keystrokes over the wire.
    Triggering is bounded to sequences the user already authored via the editor,
    and to the existing config.Validate limits.
  • Gate. Rides the existing EnableActions flag, exactly like the
    power/media/reboot commands. Flagging honestly that this flag currently
    defaults on; if you'd prefer keyboard triggering behind a dedicated default-off
    flag, that's an additive change — happy to do it.
  • Treat publish access as physical keyboard access. Triggering a macro is
    equivalent to typing on the console keyboard; the broker should be
    authenticated and ideally TLS-protected (a deployment concern the firmware
    can't enforce).
  • No replay. QoS 0 subscription + retained-message drop, so a macro is never
    replayed from the broker session or retained state. (Residual: a broker that
    also queues QoS 0 for offline persistent sessions could still replay; the
    common default does not.)
Backwards compatibility

No behavior change when MQTT is disabled or actions are off. No config schema
changes — reuses the existing KeyboardMacro/KeyboardMacroStep types and the
existing macro-execution path. The new topic and buttons only exist when MQTT is
enabled, and the command is rejected (and buttons removed) unless EnableActions
is set. No UI changes.

Follow-ups
  • Free-text (keyboard_text) and ad-hoc hotkey (keyboard_keys) injection over
    MQTT were deliberately left out: arbitrary keystroke injection is a larger
    security surface than a curated macro selector, and free-text additionally
    needs on-device keyboard-layout tables (today the layout mapping lives only in
    the frontend, ui/src/keyboardLayouts/*). Either could be added later behind a
    dedicated default-off flag, and free-text could source its layout data from the
    existing UI tables via codegen rather than hand-maintained duplicates.

Exposes stored keyboard macros over MQTT: publishing a macro's ID (or
case-insensitive name) to a new keyboard_macro/set command topic runs it,
gated on the existing EnableActions flag like the other MQTT commands.

The macro is actuated through the same session-independent
rpcExecuteKeyboardMacro path the browser uses, so automation (Home
Assistant, scripts) can trigger a macro while a WebRTC session stays
connected. The key/modifier name -> HID code tables are ported from the
web UI (ui/src/keyboardMappings.ts) into a new internal/keyboard package;
macros are layout-independent because they are authored as key names, not
characters.

Home Assistant discovery publishes one button per stored macro,
slot-indexed (macro_1 .. macro_<MaxMacrosPerDevice>) so retained configs
for deleted or renamed macros are always cleared, even across reboots.
setKeyboardMacros republishes discovery so edits appear immediately.

rpcExecuteKeyboardMacro previously assumed a single serial caller (the
WebRTC session HID queue); MQTT adds concurrent callers, so it now cancels
any running macro and serializes execution to prevent overlapping macros
from interleaving keystrokes on the shared gadget.

Keyboard commands are subscribed at QoS 0 and retained messages dropped,
so a macro is never replayed from the broker session or retained state.

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

CLAassistant commented Jul 2, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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.

Trigger stored keyboard macros over MQTT

2 participants