From fb6537e9102bfea8390fc6bd8b027c82b74ba21a Mon Sep 17 00:00:00 2001 From: Mark Francis Date: Thu, 2 Jul 2026 11:27:49 -0400 Subject: [PATCH] Add MQTT command to trigger stored keyboard macros 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_) 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 --- internal/keyboard/keymap.go | 337 ++++++++++++++++++++++++++++++++++++ jsonrpc.go | 18 ++ mqtt_commands.go | 86 +++++++++ mqtt_discovery.go | 41 ++++- 4 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 internal/keyboard/keymap.go diff --git a/internal/keyboard/keymap.go b/internal/keyboard/keymap.go new file mode 100644 index 000000000..689d9bd9e --- /dev/null +++ b/internal/keyboard/keymap.go @@ -0,0 +1,337 @@ +// Package keyboard translates stored keyboard macros into the wire-level HID +// macro steps consumed by the macro executor (rpcExecuteKeyboardMacro). The key +// and modifier vocabulary is the same one the web UI stores into keyboard +// macros (ui/src/keyboardMappings.ts). +package keyboard + +import ( + "fmt" + + "github.com/jetkvm/kvm/internal/hidrpc" +) + +// MacroStep is one authored macro step: named keys pressed together with the +// named modifiers held, then released. It mirrors the config-level +// KeyboardMacroStep. +type MacroStep struct { + Keys []string + Modifiers []string + Delay int +} + +// keyCodes maps a key name to its USB HID usage ID. Ported from +// ui/src/keyboardMappings.ts `export const keys` so the device agrees with the +// names the web UI stores into keyboard macros. +var keyCodes = map[string]byte{ + "Again": 0x79, + "AlternateErase": 0x9d, + "AltGr": 0xe6, // aka AltRight + "AltLeft": 0xe2, + "AltRight": 0xe6, + "Application": 0x65, + "ArrowDown": 0x51, + "ArrowLeft": 0x50, + "ArrowRight": 0x4f, + "ArrowUp": 0x52, + "Attention": 0x9a, + "Backquote": 0x35, // aka Grave + "Backslash": 0x31, + "Backspace": 0x2a, + "BracketLeft": 0x2f, // aka LeftBrace + "BracketRight": 0x30, // aka RightBrace + "Cancel": 0x9b, + "CapsLock": 0x39, + "Clear": 0x9c, + "ClearAgain": 0xa2, + "Comma": 0x36, + "Compose": 0xe3, + "ContextMenu": 0x65, + "ControlLeft": 0xe0, + "ControlRight": 0xe4, + "Copy": 0x7c, + "CrSel": 0xa3, + "CurrencySubunit": 0xb5, + "CurrencyUnit": 0xb4, + "Cut": 0x7b, + "DecimalSeparator": 0xb3, + "Delete": 0x4c, + "Digit0": 0x27, + "Digit1": 0x1e, + "Digit2": 0x1f, + "Digit3": 0x20, + "Digit4": 0x21, + "Digit5": 0x22, + "Digit6": 0x23, + "Digit7": 0x24, + "Digit8": 0x25, + "Digit9": 0x26, + "End": 0x4d, + "Enter": 0x28, + "Equal": 0x2e, + "Escape": 0x29, + "Execute": 0x74, + "ExSel": 0xa4, + "F1": 0x3a, + "F2": 0x3b, + "F3": 0x3c, + "F4": 0x3d, + "F5": 0x3e, + "F6": 0x3f, + "F7": 0x40, + "F8": 0x41, + "F9": 0x42, + "F10": 0x43, + "F11": 0x44, + "F12": 0x45, + "F13": 0x68, + "F14": 0x69, + "F15": 0x6a, + "F16": 0x6b, + "F17": 0x6c, + "F18": 0x6d, + "F19": 0x6e, + "F20": 0x6f, + "F21": 0x70, + "F22": 0x71, + "F23": 0x72, + "F24": 0x73, + "Find": 0x7e, + "Grave": 0x35, + "HashTilde": 0x32, // non-US # and ~ + "Help": 0x75, + "Home": 0x4a, + "Insert": 0x49, + "International7": 0x8d, + "International8": 0x8e, + "International9": 0x8f, + "IntlBackslash": 0x64, // non-US \ and | + "KeyA": 0x04, + "KeyB": 0x05, + "KeyC": 0x06, + "KeyD": 0x07, + "KeyE": 0x08, + "KeyF": 0x09, + "KeyG": 0x0a, + "KeyH": 0x0b, + "KeyI": 0x0c, + "KeyJ": 0x0d, + "KeyK": 0x0e, + "KeyL": 0x0f, + "KeyM": 0x10, + "KeyN": 0x11, + "KeyO": 0x12, + "KeyP": 0x13, + "KeyQ": 0x14, + "KeyR": 0x15, + "KeyS": 0x16, + "KeyT": 0x17, + "KeyU": 0x18, + "KeyV": 0x19, + "KeyW": 0x1a, + "KeyX": 0x1b, + "KeyY": 0x1c, + "KeyZ": 0x1d, + "KeyRO": 0x87, + "KatakanaHiragana": 0x88, + "Yen": 0x89, + "Henkan": 0x8a, + "Muhenkan": 0x8b, + "KPJPComma": 0x8c, + "Hangeul": 0x90, + "Hanja": 0x91, + "Katakana": 0x92, + "Hiragana": 0x93, + "ZenkakuHankaku": 0x94, + "LockingCapsLock": 0x82, + "LockingNumLock": 0x83, + "LockingScrollLock": 0x84, + "Lang6": 0x95, + "Lang7": 0x96, + "Lang8": 0x97, + "Lang9": 0x98, + "Menu": 0x76, + "MetaLeft": 0xe3, + "MetaRight": 0xe7, + "Minus": 0x2d, + "Mute": 0x7f, + "NumLock": 0x53, // and Clear + "Numpad0": 0x62, // and Insert + "Numpad00": 0xb0, + "Numpad000": 0xb1, + "Numpad1": 0x59, // and End + "Numpad2": 0x5a, // and Down Arrow + "Numpad3": 0x5b, // and Page Down + "Numpad4": 0x5c, // and Left Arrow + "Numpad5": 0x5d, + "Numpad6": 0x5e, // and Right Arrow + "Numpad7": 0x5f, // and Home + "Numpad8": 0x60, // and Up Arrow + "Numpad9": 0x61, // and Page Up + "NumpadAdd": 0x57, + "NumpadAnd": 0xc7, + "NumpadAt": 0xce, + "NumpadBackspace": 0xbb, + "NumpadBinary": 0xda, + "NumpadCircumflex": 0xc3, + "NumpadClear": 0xd8, + "NumpadClearEntry": 0xd9, + "NumpadColon": 0xcb, + "NumpadComma": 0x85, + "NumpadDecimal": 0x63, // and Delete + "NumpadDecimalBase": 0xdc, + "NumpadDelete": 0x63, + "NumpadDivide": 0x54, + "NumpadDownArrow": 0x5a, + "NumpadEnd": 0x59, + "NumpadEnter": 0x58, + "NumpadEqual": 0x67, + "NumpadExclamation": 0xcf, + "NumpadGreaterThan": 0xc6, + "NumpadHexadecimal": 0xdd, + "NumpadHome": 0x5f, + "NumpadKeyA": 0xbc, + "NumpadKeyB": 0xbd, + "NumpadKeyC": 0xbe, + "NumpadKeyD": 0xbf, + "NumpadKeyE": 0xc0, + "NumpadKeyF": 0xc1, + "NumpadLeftArrow": 0x5c, + "NumpadLeftBrace": 0xb8, + "NumpadLeftParen": 0xb6, + "NumpadLessThan": 0xc5, + "NumpadLogicalAnd": 0xc8, + "NumpadLogicalOr": 0xca, + "NumpadMemoryAdd": 0xd3, + "NumpadMemoryClear": 0xd2, + "NumpadMemoryDivide": 0xd6, + "NumpadMemoryMultiply": 0xd5, + "NumpadMemoryRecall": 0xd1, + "NumpadMemoryStore": 0xd0, + "NumpadMemorySubtract": 0xd4, + "NumpadMultiply": 0x55, + "NumpadOctal": 0xdb, + "NumpadOctathorpe": 0xcc, + "NumpadOr": 0xc9, + "NumpadPageDown": 0x5b, + "NumpadPageUp": 0x61, + "NumpadPercent": 0xc4, + "NumpadPlusMinus": 0xd7, + "NumpadRightArrow": 0x5e, + "NumpadRightBrace": 0xb9, + "NumpadRightParen": 0xb7, + "NumpadSpace": 0xcd, + "NumpadSubtract": 0x56, + "NumpadTab": 0xba, + "NumpadUpArrow": 0x60, + "NumpadXOR": 0xc2, + "Octothorpe": 0x32, // non-US # and ~ + "Operation": 0xa1, + "Out": 0xa0, + "PageDown": 0x4e, + "PageUp": 0x4b, + "Paste": 0x7d, + "Pause": 0x48, + "Period": 0x37, // aka Dot + "Power": 0x66, + "PrintScreen": 0x46, + "Prior": 0x9d, + "Quote": 0x34, // aka Single Quote or Apostrophe + "Return": 0x9e, + "ScrollLock": 0x47, + "Select": 0x77, + "Semicolon": 0x33, + "Separator": 0x9f, + "ShiftLeft": 0xe1, + "ShiftRight": 0xe5, + "Slash": 0x38, + "Space": 0x2c, + "Stop": 0x78, + "SystemRequest": 0x9a, // aka Attention + "Tab": 0x2b, + "ThousandsSeparator": 0xb2, + "Tilde": 0x35, + "Undo": 0x7a, + "VolumeDown": 0x81, + "VolumeUp": 0x80, +} + +// modifierMasks maps a modifier name to its bit in the HID report modifier +// byte. Ported from ui/src/keyboardMappings.ts `export const modifiers`. +var modifierMasks = map[string]byte{ + "ControlLeft": 0x01, + "ControlRight": 0x10, + "ShiftLeft": 0x02, + "ShiftRight": 0x20, + "AltLeft": 0x04, + "AltRight": 0x40, + "MetaLeft": 0x08, + "MetaRight": 0x80, + "AltGr": 0x40, +} + +// Timing for converted macro steps, mirroring the web UI's macro execution +// (executeMacroRemote in ui/src/hooks/useKeyboard.ts): each press is held for +// 20ms, and a release step with no delay of its own falls back to 100ms. +const ( + pressDelayMs = 20 + defaultDelayMs = 100 +) + +// HIDSteps converts authored macro steps into the wire-level steps consumed +// by rpcExecuteKeyboardMacro, following the web UI's macro execution +// (executeMacroRemote in ui/src/hooks/useKeyboard.ts): every step becomes a +// press report held for pressDelayMs followed by an all-zero release report +// carrying the step's delay. The explicit release matters — +// rpcDoExecuteKeyboardMacro plays reports as-is, so without it keys and +// modifiers would stay held on the host and bleed into the next step. Steps +// with nothing to press are skipped, as in the web UI. +// +// Two intentional divergences from the web UI, both toward failing closed for +// programmatic input: an unknown key or modifier name aborts the whole macro +// with an error (the web UI silently drops unknown keys), and keys beyond the +// 6-slot HID report are dropped to match the usbgadget's own KeyboardReport +// truncation (internal/usbgadget/hid_keyboard.go) rather than erroring as the +// web UI's remote path does. +func HIDSteps(steps []MacroStep) ([]hidrpc.KeyboardMacroStep, error) { + hidSteps := make([]hidrpc.KeyboardMacroStep, 0, len(steps)*2) + + for i, step := range steps { + if len(step.Keys) == 0 && len(step.Modifiers) == 0 { + continue + } + + var modifier byte + for _, name := range step.Modifiers { + mask, ok := modifierMasks[name] + if !ok { + return nil, fmt.Errorf("unknown modifier %q in step %d", name, i+1) + } + modifier |= mask + } + + keys := make([]byte, hidrpc.HidKeyBufferSize) + for j, name := range step.Keys { + code, ok := keyCodes[name] + if !ok { + return nil, fmt.Errorf("unknown key %q in step %d", name, i+1) + } + if j < hidrpc.HidKeyBufferSize { + keys[j] = code + } + } + + delay := step.Delay + if delay <= 0 { + delay = defaultDelayMs + } else if delay > 65535 { + delay = 65535 + } + + hidSteps = append(hidSteps, + hidrpc.KeyboardMacroStep{Modifier: modifier, Keys: keys, Delay: pressDelayMs}, + hidrpc.KeyboardMacroStep{Keys: make([]byte, hidrpc.HidKeyBufferSize), Delay: uint16(delay)}, + ) + } + + return hidSteps, nil +} diff --git a/jsonrpc.go b/jsonrpc.go index 5a662530d..1b17d7e5b 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1149,6 +1149,11 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) { return nil, err } + // Keep the Home Assistant macro buttons in sync with the stored macros + if mqttManager != nil { + mqttManager.republishHADiscovery() + } + return nil, nil } @@ -1230,6 +1235,14 @@ func rpcEmitTestLog(level string) error { var ( keyboardMacroCancel context.CancelFunc keyboardMacroLock sync.Mutex + + // keyboardMacroRun serializes macro execution. Executions share + // keyboardMacroCancel and drive the same global HID gadget, so they must + // not overlap. Historically the only caller was the single WebRTC + // session's HID queue (inherently serial); MQTT keyboard commands add + // concurrent callers, so a new execution now cancels the running one and + // waits for it to unwind before starting, instead of interleaving reports. + keyboardMacroRun sync.Mutex ) // cancelKeyboardMacro cancels any ongoing keyboard macro execution @@ -1252,7 +1265,12 @@ func setKeyboardMacroCancel(cancel context.CancelFunc) { } func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { + // Cancel any running macro, then wait for it to actually finish before + // starting this one — see keyboardMacroRun. cancelKeyboardMacro makes the + // in-flight macro return promptly at its next step, so this rarely blocks. cancelKeyboardMacro() + keyboardMacroRun.Lock() + defer keyboardMacroRun.Unlock() ctx, cancel := context.WithCancel(context.Background()) setKeyboardMacroCancel(cancel) diff --git a/mqtt_commands.go b/mqtt_commands.go index 7dbb90bff..e6b694b3e 100644 --- a/mqtt_commands.go +++ b/mqtt_commands.go @@ -1,9 +1,14 @@ package kvm import ( + "context" + "errors" "strings" "time" + "github.com/jetkvm/kvm/internal/hidrpc" + "github.com/jetkvm/kvm/internal/keyboard" + mqtt "github.com/eclipse/paho.mqtt.golang" ) @@ -28,6 +33,18 @@ func (m *MQTTManager) subscribeCommands() { } } + // The keyboard macro command is subscribed at QoS 0, unlike the commands + // above: with the persistent session (CleanSession false), a QoS 1 + // subscription makes the broker queue commands published while the device + // is offline and replay them on reconnect, and a macro must never be + // replayed into the attached host. Brokers that do not queue QoS 0 for + // offline persistent sessions (the common default) avoid that entirely; + // brokers that do queue QoS 0 are a residual risk noted in the PR. + // Retained messages are dropped in the handler. + if token := m.client.Subscribe(m.topic("keyboard_macro", "set"), 0, m.handleKeyboardMacroCommand); token.Wait() && token.Error() != nil { + mqttLogger.Error().Err(token.Error()).Str("topic", m.topic("keyboard_macro", "set")).Msg("failed to subscribe") + } + mqttLogger.Info().Msg("subscribed to command topics") } @@ -223,3 +240,72 @@ func (m *MQTTManager) handleVirtualMediaCommand(client mqtt.Client, msg mqtt.Mes // Publish updated state immediately m.publishVirtualMediaState() } + +// findKeyboardMacro locates a stored keyboard macro by ID, or by name +// (case-insensitive) if no ID matches. +func findKeyboardMacro(nameOrID string) (*KeyboardMacro, bool) { + for i := range config.KeyboardMacros { + if config.KeyboardMacros[i].ID == nameOrID { + return &config.KeyboardMacros[i], true + } + } + for i := range config.KeyboardMacros { + if strings.EqualFold(config.KeyboardMacros[i].Name, nameOrID) { + return &config.KeyboardMacros[i], true + } + } + return nil, false +} + +// macroHIDSteps converts a stored macro's steps into wire-level HID steps. +func macroHIDSteps(steps []KeyboardMacroStep) ([]hidrpc.KeyboardMacroStep, error) { + macroSteps := make([]keyboard.MacroStep, len(steps)) + for i, step := range steps { + macroSteps[i] = keyboard.MacroStep(step) + } + return keyboard.HIDSteps(macroSteps) +} + +// executeKeyboardSteps runs converted macro steps on a new goroutine — the +// steps sleep between reports, and paho delivers all messages on one ordered +// goroutine, so executing inline would stall every other command handler. +// rpcExecuteKeyboardMacro cancels any running macro and serializes execution, +// so overlapping commands run one after another rather than interleaving. +func executeKeyboardSteps(source string, steps []hidrpc.KeyboardMacroStep) { + go func() { + if err := rpcExecuteKeyboardMacro(steps); err != nil { + if errors.Is(err, context.Canceled) { + mqttLogger.Info().Str("source", source).Msg("keyboard input canceled") + return + } + mqttLogger.Error().Err(err).Str("source", source).Msg("failed to execute keyboard input") + } + }() +} + +func (m *MQTTManager) handleKeyboardMacroCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("keyboard macro command rejected: actions are disabled") + return + } + if msg.Retained() { + mqttLogger.Warn().Msg("ignoring retained keyboard macro command") + return + } + payload := strings.TrimSpace(string(msg.Payload())) + mqttLogger.Info().Str("payload", payload).Msg("received keyboard macro command") + + macro, ok := findKeyboardMacro(payload) + if !ok { + mqttLogger.Warn().Str("payload", payload).Msg("unknown keyboard macro") + return + } + + steps, err := macroHIDSteps(macro.Steps) + if err != nil { + mqttLogger.Error().Err(err).Str("macro", macro.Name).Msg("failed to convert keyboard macro") + return + } + + executeKeyboardSteps("macro "+macro.Name, steps) +} diff --git a/mqtt_discovery.go b/mqtt_discovery.go index e86df4760..929d31a73 100644 --- a/mqtt_discovery.go +++ b/mqtt_discovery.go @@ -412,6 +412,9 @@ func (m *MQTTManager) publishHADiscovery() { m.removeDiscovery("button", "reboot") } + // Keyboard macro buttons: one per stored macro when actions enabled + m.publishMacroDiscovery(device, availTopic, availTemplate, actionsEnabled) + // Firmware Update: always published, but command_topic only when actions enabled. // NOTE: Do NOT use value_template/latest_version_template here — HA needs to parse // the full JSON directly to recognize in_progress and update_percentage fields. @@ -633,6 +636,33 @@ func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemp } } +// publishMacroDiscovery publishes one Home Assistant button per stored keyboard +// macro. Buttons are addressed by slot (macro_1 .. macro_) +// rather than macro ID so that buttons for deleted or renamed macros — +// including ones published before a reboot — are always cleared without +// tracking extra state; the button's payload_press carries the macro ID, which +// handleKeyboardMacroCommand resolves. +func (m *MQTTManager) publishMacroDiscovery(device *haDevice, availTopic, availTemplate string, actionsEnabled bool) { + for i := 1; i <= MaxMacrosPerDevice; i++ { + objectID := fmt.Sprintf("macro_%d", i) + if actionsEnabled && i <= len(config.KeyboardMacros) { + macro := config.KeyboardMacros[i-1] + m.publishDiscovery("button", objectID, haDiscoveryPayload{ + Name: "Macro: " + macro.Name, + UniqueID: fmt.Sprintf("jetkvm_%s_%s", m.deviceID, objectID), + CommandTopic: m.topic("keyboard_macro", "set"), + PayloadPress: macro.ID, + Icon: "mdi:keyboard-outline", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } else { + m.removeDiscovery("button", objectID) + } + } +} + func (m *MQTTManager) publishDiscovery(component, objectID string, payload haDiscoveryPayload) { payload.DefaultEntityID = fmt.Sprintf("%s.jetkvm_%s_%s", component, m.deviceID, objectID) discoveryTopic := fmt.Sprintf("homeassistant/%s/jetkvm_%s/%s/config", component, m.deviceID, objectID) @@ -691,6 +721,10 @@ func (m *MQTTManager) removeAllDiscovery() { m.removeDiscovery("binary_sensor", "jiggler") m.removeDiscovery("button", "reboot") m.removeDiscovery("update", "firmware") + // Keyboard macro buttons (all slots) + for i := 1; i <= MaxMacrosPerDevice; i++ { + m.removeDiscovery("button", fmt.Sprintf("macro_%d", i)) + } // Extension-specific entities (both switch and binary_sensor variants) m.removeATXDiscovery() @@ -728,8 +762,9 @@ func (m *MQTTManager) cleanupAllTopics() { mqttLogger.Info().Msg("cleaned up all MQTT topics and discovery entries") } -// republishHADiscovery removes old extension entities and re-publishes all discovery configs. -// Call this when the active extension changes. +// republishHADiscovery re-publishes all discovery configs (removing entities +// that no longer apply). Call this after a change that affects discovery, such +// as the active extension changing or stored keyboard macros being edited. func (m *MQTTManager) republishHADiscovery() { if !m.IsConnected() { return @@ -741,5 +776,5 @@ func (m *MQTTManager) republishHADiscovery() { // Re-publish all discovery configs (publishHADiscovery handles removal of inactive extension entities) m.publishHADiscovery() - mqttLogger.Info().Str("extension", config.ActiveExtension).Msg("republished HA discovery after extension change") + mqttLogger.Info().Str("extension", config.ActiveExtension).Msg("republished HA discovery") }