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") }