diff --git a/jsonrpc.go b/jsonrpc.go index 6e4276d2..e8ce6d14 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -806,9 +806,9 @@ func rpcGetATXState() (ATXState, error) { return state, nil } -func rpcSendCustomCommand(command string) error { +func rpcSendCustomCommand(command string, terminator string) error { logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command") - err := sendCustomCommand(command) + err := sendCustomCommand(command, terminator) if err != nil { return fmt.Errorf("failed to send custom command in jsonrpc: %w", err) } @@ -906,59 +906,49 @@ func rpcSetSerialSettings(settings SerialSettings) error { return nil } -type QuickButton struct { - Id string `json:"id"` // uuid-ish - Label string `json:"label"` // shown on the button - Command string `json:"command"` // raw command to send (without auto-terminator) - Sort int `json:"sort"` // for stable ordering +func rpcGetSerialButtonConfig() (CustomButtonSettings, error) { + return getSerialSettings() } -type SerialButtonConfig struct { - Buttons []QuickButton `json:"buttons"` // slice of QuickButton - Terminator string `json:"terminator"` // CR/CRLF/None - HideSerialSettings bool `json:"hideSerialSettings"` // lowercase `bool` - HideSerialResponse bool `json:"hideSerialResponse"` // lowercase `bool` +func rpcSetSerialButtonConfig(config CustomButtonSettings) error { + return setSerialSettings(config) } -func rpcGetSerialButtonConfig() (SerialButtonConfig, error) { - config := SerialButtonConfig{ - Buttons: []QuickButton{}, - Terminator: "\r", - HideSerialSettings: false, - HideSerialResponse: true, - } +const SerialCommandHistoryPath = "/userdata/serialCommandHistory.json" - file, err := os.Open("/userdata/serialButtons_config.json") +func rpcGetSerialCommandHistory() ([]string, error) { + items := []string{} + + file, err := os.Open(SerialCommandHistoryPath) if err != nil { - logger.Debug().Msg("SerialButtons config file doesn't exist, using default") - return config, nil + logger.Debug().Msg("SerialCommandHistory file doesn't exist, using default") + return items, nil } defer file.Close() // load and merge the default config with the user config - var loadedConfig SerialButtonConfig - if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { - logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed") - return config, nil + var loadedItems []string + if err := json.NewDecoder(file).Decode(&loadedItems); err != nil { + logger.Warn().Err(err).Msg("SerialCommandHistory file JSON parsing failed") + return items, nil } - return loadedConfig, nil + return loadedItems, nil } -func rpcSetSerialButtonConfig(config SerialButtonConfig) error { +func rpcSetSerialCommandHistory(commandHistory []string) error { + logger.Trace().Str("path", SerialCommandHistoryPath).Msg("Saving serial command history") - logger.Trace().Str("path", "/userdata/serialButtons_config.json").Msg("Saving config") - - file, err := os.Create("/userdata/serialButtons_config.json") + file, err := os.Create(SerialCommandHistoryPath) if err != nil { - return fmt.Errorf("failed to create SerialButtons config file: %w", err) + return fmt.Errorf("failed to create SerialCommandHistory file: %w", err) } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") - if err := encoder.Encode(config); err != nil { - return fmt.Errorf("failed to encode SerialButtons config: %w", err) + if err := encoder.Encode(commandHistory); err != nil { + return fmt.Errorf("failed to encode SerialCommandHistory: %w", err) } return nil @@ -1239,91 +1229,93 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro } var rpcHandlers = map[string]RPCHandler{ - "ping": {Func: rpcPing}, - "reboot": {Func: rpcReboot, Params: []string{"force"}}, - "getDeviceID": {Func: rpcGetDeviceID}, - "deregisterDevice": {Func: rpcDeregisterDevice}, - "getCloudState": {Func: rpcGetCloudState}, - "getNetworkState": {Func: rpcGetNetworkState}, - "getNetworkSettings": {Func: rpcGetNetworkSettings}, - "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, - "renewDHCPLease": {Func: rpcRenewDHCPLease}, - "getKeyboardLedState": {Func: rpcGetKeyboardLedState}, - "getKeyDownState": {Func: rpcGetKeysDownState}, - "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, - "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, - "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, - "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, - "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, - "getVideoState": {Func: rpcGetVideoState}, - "getUSBState": {Func: rpcGetUSBState}, - "unmountImage": {Func: rpcUnmountImage}, - "rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, - "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, - "getJigglerState": {Func: rpcGetJigglerState}, - "setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, - "getJigglerConfig": {Func: rpcGetJigglerConfig}, - "getTimezones": {Func: rpcGetTimezones}, - "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, - "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, - "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, - "getAutoUpdateState": {Func: rpcGetAutoUpdateState}, - "setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}}, - "getEDID": {Func: rpcGetEDID}, - "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, - "getDevChannelState": {Func: rpcGetDevChannelState}, - "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, - "getLocalVersion": {Func: rpcGetLocalVersion}, - "getUpdateStatus": {Func: rpcGetUpdateStatus}, - "tryUpdate": {Func: rpcTryUpdate}, - "getDevModeState": {Func: rpcGetDevModeState}, - "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, - "getSSHKeyState": {Func: rpcGetSSHKeyState}, - "setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, - "getTLSState": {Func: rpcGetTLSState}, - "setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}}, - "setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, - "getMassStorageMode": {Func: rpcGetMassStorageMode}, - "isUpdatePending": {Func: rpcIsUpdatePending}, - "getUsbEmulationState": {Func: rpcGetUsbEmulationState}, - "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, - "getUsbConfig": {Func: rpcGetUsbConfig}, - "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, - "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, - "getVirtualMediaState": {Func: rpcGetVirtualMediaState}, - "getStorageSpace": {Func: rpcGetStorageSpace}, - "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, - "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, - "listStorageFiles": {Func: rpcListStorageFiles}, - "deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}}, - "startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}}, - "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, - "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, - "resetConfig": {Func: rpcResetConfig}, - "setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}}, - "getDisplayRotation": {Func: rpcGetDisplayRotation}, - "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, - "getBacklightSettings": {Func: rpcGetBacklightSettings}, - "getDCPowerState": {Func: rpcGetDCPowerState}, - "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, - "setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}}, - "getActiveExtension": {Func: rpcGetActiveExtension}, - "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, - "getATXState": {Func: rpcGetATXState}, - "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, - "sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}}, - "getSerialSettings": {Func: rpcGetSerialSettings}, - "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, - "getSerialButtonConfig": {Func: rpcGetSerialButtonConfig}, - "setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}}, - "getUsbDevices": {Func: rpcGetUsbDevices}, - "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, - "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, - "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, - "getKeyboardLayout": {Func: rpcGetKeyboardLayout}, - "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, - "getKeyboardMacros": {Func: getKeyboardMacros}, - "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, - "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, - "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "ping": {Func: rpcPing}, + "reboot": {Func: rpcReboot, Params: []string{"force"}}, + "getDeviceID": {Func: rpcGetDeviceID}, + "deregisterDevice": {Func: rpcDeregisterDevice}, + "getCloudState": {Func: rpcGetCloudState}, + "getNetworkState": {Func: rpcGetNetworkState}, + "getNetworkSettings": {Func: rpcGetNetworkSettings}, + "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, + "renewDHCPLease": {Func: rpcRenewDHCPLease}, + "getKeyboardLedState": {Func: rpcGetKeyboardLedState}, + "getKeyDownState": {Func: rpcGetKeysDownState}, + "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, + "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, + "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, + "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, + "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, + "getVideoState": {Func: rpcGetVideoState}, + "getUSBState": {Func: rpcGetUSBState}, + "unmountImage": {Func: rpcUnmountImage}, + "rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, + "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, + "getJigglerState": {Func: rpcGetJigglerState}, + "setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, + "getJigglerConfig": {Func: rpcGetJigglerConfig}, + "getTimezones": {Func: rpcGetTimezones}, + "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, + "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, + "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, + "getAutoUpdateState": {Func: rpcGetAutoUpdateState}, + "setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}}, + "getEDID": {Func: rpcGetEDID}, + "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, + "getDevChannelState": {Func: rpcGetDevChannelState}, + "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, + "getLocalVersion": {Func: rpcGetLocalVersion}, + "getUpdateStatus": {Func: rpcGetUpdateStatus}, + "tryUpdate": {Func: rpcTryUpdate}, + "getDevModeState": {Func: rpcGetDevModeState}, + "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, + "getSSHKeyState": {Func: rpcGetSSHKeyState}, + "setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, + "getTLSState": {Func: rpcGetTLSState}, + "setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}}, + "setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, + "getMassStorageMode": {Func: rpcGetMassStorageMode}, + "isUpdatePending": {Func: rpcIsUpdatePending}, + "getUsbEmulationState": {Func: rpcGetUsbEmulationState}, + "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, + "getUsbConfig": {Func: rpcGetUsbConfig}, + "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, + "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, + "getVirtualMediaState": {Func: rpcGetVirtualMediaState}, + "getStorageSpace": {Func: rpcGetStorageSpace}, + "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, + "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, + "listStorageFiles": {Func: rpcListStorageFiles}, + "deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}}, + "startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}}, + "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, + "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, + "resetConfig": {Func: rpcResetConfig}, + "setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}}, + "getDisplayRotation": {Func: rpcGetDisplayRotation}, + "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, + "getBacklightSettings": {Func: rpcGetBacklightSettings}, + "getDCPowerState": {Func: rpcGetDCPowerState}, + "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, + "setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}}, + "getActiveExtension": {Func: rpcGetActiveExtension}, + "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, + "getATXState": {Func: rpcGetATXState}, + "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, + "sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command", "terminator"}}, + "getSerialSettings": {Func: rpcGetSerialSettings}, + "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, + "getSerialButtonConfig": {Func: rpcGetSerialButtonConfig}, + "setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}}, + "getSerialCommandHistory": {Func: rpcGetSerialCommandHistory}, + "setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}}, + "getUsbDevices": {Func: rpcGetUsbDevices}, + "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, + "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, + "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, + "getKeyboardLayout": {Func: rpcGetKeyboardLayout}, + "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, + "getKeyboardMacros": {Func: getKeyboardMacros}, + "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, + "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, + "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, } diff --git a/serial.go b/serial.go index 9d77e5bc..fbf3d965 100644 --- a/serial.go +++ b/serial.go @@ -3,7 +3,10 @@ package kvm import ( "bufio" "encoding/base64" + "encoding/json" + "fmt" "io" + "os" "strconv" "strings" "time" @@ -272,7 +275,7 @@ func startSerialButtonsRxLoop(session *Session) { scopedLogger.Debug().Msg("Attempting to start RX reader.") // Stop previous loop if running if serialButtonsRXStopCh != nil { - close(serialButtonsRXStopCh) + stopSerialButtonsRxLoop() } serialButtonsRXStopCh = make(chan struct{}) @@ -285,6 +288,10 @@ func startSerialButtonsRxLoop(session *Session) { case <-serialButtonsRXStopCh: return default: + if currentSession == nil { + time.Sleep(500 * time.Millisecond) + continue + } n, err := port.Read(buf) if err != nil { if err != io.EOF { @@ -293,7 +300,7 @@ func startSerialButtonsRxLoop(session *Session) { time.Sleep(50 * time.Millisecond) continue } - if n == 0 || currentSession == nil { + if n == 0 { continue } // Safe for any bytes: wrap in Base64 @@ -307,23 +314,25 @@ func startSerialButtonsRxLoop(session *Session) { } func stopSerialButtonsRxLoop() { + scopedLogger := serialLogger.With().Str("service", "custom_buttons_rx").Logger() + scopedLogger.Debug().Msg("Stopping RX reader.") if serialButtonsRXStopCh != nil { close(serialButtonsRXStopCh) serialButtonsRXStopCh = nil } } -func sendCustomCommand(command string) error { +func sendCustomCommand(command string, terminator string) error { scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger() scopedLogger.Info().Str("Command", command).Msg("Sending custom command.") - _, err := port.Write([]byte("\n")) + _, err := port.Write([]byte(terminator)) if err != nil { - scopedLogger.Warn().Err(err).Msg("Failed to send serial output \\n") + scopedLogger.Warn().Err(err).Msg("Failed to send terminator") return err } _, err = port.Write([]byte(command)) if err != nil { - scopedLogger.Warn().Err(err).Str("line", command).Msg("Failed to send serial output") + scopedLogger.Warn().Err(err).Str("Command", command).Msg("Failed to send serial command") return err } return nil @@ -336,6 +345,149 @@ var defaultMode = &serial.Mode{ StopBits: serial.OneStopBit, } +const serialSettingsPath = "/userdata/serialSettings.json" + +type Terminator struct { + Label string `json:"label"` // Terminator label + Value string `json:"value"` // Terminator value +} + +type QuickButton struct { + Id string `json:"id"` // Unique identifier + Label string `json:"label"` // Button label + Command string `json:"command"` // Command to send, raw command to send (without auto-terminator) + Terminator Terminator `json:"terminator"` // Terminator to use: None/CR/LF/CRLF/LFCR + Sort int `json:"sort"` // Sort order +} + +// Mode describes a serial port configuration. +type CustomButtonSettings struct { + BaudRate string `json:"baudRate"` // The serial port bitrate (aka Baudrate) + DataBits string `json:"dataBits"` // Size of the character (must be 5, 6, 7 or 8) + Parity string `json:"parity"` // Parity (see Parity type for more info) + StopBits string `json:"stopBits"` // Stop bits (see StopBits type for more info) + Terminator Terminator `json:"terminator"` // Terminator to send after each command + LineMode bool `json:"lineMode"` // Whether to send each line when Enter is pressed, or each character immediately + HideSerialSettings bool `json:"hideSerialSettings"` // Whether to hide the serial settings in the UI + EnableEcho bool `json:"enableEcho"` // Whether to echo received characters back to the sender + Buttons []QuickButton `json:"buttons"` // Custom quick buttons +} + +func getSerialSettings() (CustomButtonSettings, error) { + config := CustomButtonSettings{ + BaudRate: strconv.Itoa(defaultMode.BaudRate), + DataBits: strconv.Itoa(defaultMode.DataBits), + Parity: "none", + StopBits: "1", + Terminator: Terminator{Label: "CR (\\r)", Value: "\r"}, + LineMode: true, + HideSerialSettings: false, + EnableEcho: false, + Buttons: []QuickButton{}, + } + + switch defaultMode.StopBits { + case serial.OneStopBit: + config.StopBits = "1" + case serial.OnePointFiveStopBits: + config.StopBits = "1.5" + case serial.TwoStopBits: + config.StopBits = "2" + } + + switch defaultMode.Parity { + case serial.NoParity: + config.Parity = "none" + case serial.OddParity: + config.Parity = "odd" + case serial.EvenParity: + config.Parity = "even" + case serial.MarkParity: + config.Parity = "mark" + case serial.SpaceParity: + config.Parity = "space" + } + + file, err := os.Open(serialSettingsPath) + if err != nil { + logger.Debug().Msg("SerialButtons config file doesn't exist, using default") + return config, err + } + defer file.Close() + + // load and merge the default config with the user config + var loadedConfig CustomButtonSettings + if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { + logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed") + return config, nil + } + + return loadedConfig, nil +} + +func setSerialSettings(newSettings CustomButtonSettings) error { + logger.Trace().Str("path", serialSettingsPath).Msg("Saving config") + + file, err := os.Create(serialSettingsPath) + if err != nil { + return fmt.Errorf("failed to create SerialButtons config file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(newSettings); err != nil { + return fmt.Errorf("failed to encode SerialButtons config: %w", err) + } + + baudRate, err := strconv.Atoi(newSettings.BaudRate) + if err != nil { + return fmt.Errorf("invalid baud rate: %v", err) + } + dataBits, err := strconv.Atoi(newSettings.DataBits) + if err != nil { + return fmt.Errorf("invalid data bits: %v", err) + } + + var stopBits serial.StopBits + switch newSettings.StopBits { + case "1": + stopBits = serial.OneStopBit + case "1.5": + stopBits = serial.OnePointFiveStopBits + case "2": + stopBits = serial.TwoStopBits + default: + return fmt.Errorf("invalid stop bits: %s", newSettings.StopBits) + } + + var parity serial.Parity + switch newSettings.Parity { + case "none": + parity = serial.NoParity + case "odd": + parity = serial.OddParity + case "even": + parity = serial.EvenParity + case "mark": + parity = serial.MarkParity + case "space": + parity = serial.SpaceParity + default: + return fmt.Errorf("invalid parity: %s", newSettings.Parity) + } + serialPortMode = &serial.Mode{ + BaudRate: baudRate, + DataBits: dataBits, + StopBits: stopBits, + Parity: parity, + } + + _ = port.SetMode(serialPortMode) + + return nil +} + func initSerialPort() { _ = reopenSerialPort() switch config.ActiveExtension { diff --git a/ui/src/components/CommandInput.tsx b/ui/src/components/CommandInput.tsx new file mode 100644 index 00000000..69f3e4d1 --- /dev/null +++ b/ui/src/components/CommandInput.tsx @@ -0,0 +1,290 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import clsx from "clsx"; + +import InputField from "@/components/InputField"; // your existing input component +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import notifications from "@/notifications"; + +interface Hit { value: string; index: number } + +// ---------- history hook ---------- +function useCommandHistory(max = 300) { + const { send } = useJsonRpc(); + const [items, setItems] = useState([]); + + useEffect(() => { + send("getSerialCommandHistory", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + `Failed to get command history: ${resp.error.data || "Unknown error"}`, + ); + } else if ("result" in resp) { + setItems(resp.result as string[]); + } + }); + }, [send]); + + const [pointer, setPointer] = useState(-1); // -1 = fresh line + const [anchorPrefix, setAnchorPrefix] = useState(null); + + useEffect(() => { + if (items.length > 1) { + send("setSerialCommandHistory", { commandHistory: items }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update command history: ${resp.error.data || "Unknown error"}`); + return; + } + }); + } + }, [items, send]); + + const push = useCallback((cmd: string) => { + if (!cmd.trim()) return; + setItems((prev) => { + const next = prev[prev.length - 1] === cmd ? prev : [...prev, cmd]; + return next.slice(-max); + }); + setPointer(-1); + setAnchorPrefix(null); + }, [max]); + + const resetTraversal = useCallback(() => { + setPointer(-1); + setAnchorPrefix(null); + }, []); + + const up = useCallback((draft: string) => { + const pref = anchorPrefix ?? draft; + if (anchorPrefix == null) setAnchorPrefix(pref); + let i = pointer < 0 ? items.length - 1 : pointer - 1; + for (; i >= 0; i--) { + if (items[i].startsWith(pref)) { + setPointer(i); + return items[i]; + } + } + return draft; + }, [items, pointer, anchorPrefix]); + + const down = useCallback((draft: string) => { + const pref = anchorPrefix ?? draft; + if (anchorPrefix == null) setAnchorPrefix(pref); + let i = pointer < 0 ? 0 : pointer + 1; + for (; i < items.length; i++) { + if (items[i].startsWith(pref)) { + setPointer(i); + return items[i]; + } + } + setPointer(-1); + return draft; + }, [items, pointer, anchorPrefix]); + + const search = useCallback((query: string): Hit[] => { + if (!query) return []; + const q = query.toLowerCase(); + return [...items] + .map((value, index) => ({ value, index })) + .filter((x) => x.value.toLowerCase().includes(q)) + .reverse(); // newest first + }, [items]); + + return { push, up, down, resetTraversal, search }; +} + +function Portal({ children }: { children: React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (!mounted) return null; + return createPortal(children, document.body); +} + +// ---------- reverse search popup ---------- +function ReverseSearch({ + open, results, sel, setSel, onPick, onClose, +}: { + open: boolean; + results: Hit[]; + sel: number; + setSel: (i: number) => void; + onPick: (val: string) => void; + onClose: () => void; +}) { + const listRef = React.useRef(null); + + // keep selected item in view when sel changes + useEffect(() => { + if (!listRef.current) return; + const el = listRef.current.querySelector(`[data-idx="${sel}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [sel, results]); + + if (!open) return null; + return ( + +
+
+ {results.length === 0 ? ( +
No matches
+ ) : results.map((r, i) => ( +
setSel(i)} + onClick={() => onPick(r.value)} + > + {r.value} +
+ ))} +
+
+ ↑/↓ select • Enter accept • Esc close + +
+
+
+ ); +} + +// ---------- main component ---------- +interface CommandInputProps { + onSend: (line: string) => void; // called on Enter + storageKey?: string; // localStorage key for history + placeholder?: string; // input placeholder + className?: string; // container className + disabled?: boolean; // disable input (optional) +} + +export function CommandInput({ + onSend, + placeholder = "Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)", + className, + disabled, +}: CommandInputProps) { + const [cmd, setCmd] = useState(""); + const [revOpen, setRevOpen] = useState(false); + const [revQuery, setRevQuery] = useState(""); + const [sel, setSel] = useState(0); + const { push, up, down, resetTraversal, search } = useCommandHistory(); + + const results = useMemo(() => search(revQuery), [revQuery, search]); + + useEffect(() => { setSel(0); }, [results]); + + const cmdInputRef = React.useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + const isMeta = e.ctrlKey || e.metaKey; + + if (e.key === "Enter" && !e.shiftKey && !isMeta) { + e.preventDefault(); + if (!cmd) return; + onSend(cmd); + push(cmd); + setCmd(""); + resetTraversal(); + setRevOpen(false); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setCmd((prev) => up(prev)); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setCmd((prev) => down(prev)); + return; + } + if (isMeta && e.key.toLowerCase() === "r") { + e.preventDefault(); + setRevOpen(true); + setRevQuery(cmd); + setSel(0); + return; + } + if (e.key === "Escape" && revOpen) { + e.preventDefault(); + setRevOpen(false); + return; + } + }; + + return ( +
+
+ CMD + { setCmd(e.target.value); resetTraversal(); }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="font-mono" + /> +
+ + {/* Reverse search controls */} + {revOpen && ( +
+
+ Search + setRevQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSel((i) => (i + 1) % Math.max(1, results.length)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSel((i) => (i - 1 + results.length) % Math.max(1, results.length)); + } else if (e.key === "Enter") { + e.preventDefault(); + const pick = results[sel]?.value ?? results[0]?.value; + if (pick) { + setCmd(pick); + setRevOpen(false); + requestAnimationFrame(() => cmdInputRef.current?.focus()); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setRevOpen(false); + requestAnimationFrame(() => cmdInputRef.current?.focus()); + } + }} + placeholder="Type to filter history…" + className="font-mono" + /> +
+ { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }} + onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}} + /> +
+ )} +
+ ); +}; + +export default CommandInput; diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index ba3e667c..75c57407 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import "react-simple-keyboard/build/css/index.css"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useCallback } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -10,9 +10,11 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; import { cx } from "@/cva.config"; import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores"; +import { CommandInput } from "@/components/CommandInput"; import { Button } from "./Button"; + const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2"); // Terminal theme configuration @@ -65,13 +67,20 @@ function Terminal({ readonly dataChannel: RTCDataChannel; readonly type: AvailableTerminalTypes; }) { - const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); + const { terminalLineMode, terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); const isTerminalTypeEnabled = useMemo(() => { + console.log("Terminal type:", terminalType, "Checking against:", type); return terminalType == type; }, [terminalType, type]); + useEffect(() => { + if (!instance) return; + instance.options.disableStdin = !terminalLineMode; + instance.options.cursorStyle = terminalLineMode ? "bar" : "block"; + }, [instance, terminalLineMode]); + useEffect(() => { setTimeout(() => { setDisableVideoFocusTrap(isTerminalTypeEnabled); @@ -161,6 +170,11 @@ function Terminal({ }; }, [instance]); + const sendLine = useCallback((line: string) => { + // Just send; echo/normalization handled elsewhere as you planned + dataChannel.send(line + "\r\n"); // adjust CR/LF to taste + }, [dataChannel]); + return (
e.stopPropagation()} @@ -199,7 +213,14 @@ function Terminal({
-
+
+ {terminalType == "serial" && terminalLineMode && ( + + )}
diff --git a/ui/src/components/extensions/SerialButtons.tsx b/ui/src/components/extensions/SerialButtons.tsx index c67a8eae..d5c2d01b 100644 --- a/ui/src/components/extensions/SerialButtons.tsx +++ b/ui/src/components/extensions/SerialButtons.tsx @@ -1,5 +1,5 @@ -import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCirclePause, LuCirclePlay } from "react-icons/lu"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCircleX, LuTerminal } from "react-icons/lu"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "@components/Button"; import Card from "@components/Card"; @@ -8,38 +8,43 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { InputFieldWithLabel } from "@components/InputField"; -import { TextAreaWithLabel } from "@components/TextArea"; +import { useUiStore } from "@/hooks/stores"; + +import Checkbox from "../../components/Checkbox"; +import { SettingsItem } from "../../routes/devices.$id.settings"; + + /** ============== Types ============== */ - -interface SerialSettings { - baudRate: string; - dataBits: string; - stopBits: string; - parity: string; -} - interface QuickButton { id: string; // uuid-ish label: string; // shown on the button command: string; // raw command to send (without auto-terminator) + terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR sort: number; // for stable ordering } -interface ButtonConfig { - buttons: QuickButton[]; - terminator: string; // CR/CRLF/None +interface CustomButtonSettings { + baudRate: string; + dataBits: string; + stopBits: string; + parity: string; + terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR + lineMode: boolean; hideSerialSettings: boolean; - hideSerialResponse: boolean; + enableEcho: boolean; // future use + buttons: QuickButton[]; } /** ============== Component ============== */ export function SerialButtons() { + const { setTerminalType, setTerminalLineMode } = useUiStore(); + // This will receive all JSON-RPC notifications (method + no id) const { send } = useJsonRpc((payload) => { if (payload.method !== "serial.rx") return; - if (paused) return; + // if (paused) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const p = payload.params as any; @@ -61,48 +66,30 @@ export function SerialButtons() { // Normalize CRLF for display chunk = chunk.replace(/\r\n/g, "\n"); - setSerialResponse(prev => (prev + chunk).slice(-MAX_CHARS)); + // setSerialResponse(prev => (prev + chunk).slice(-MAX_CHARS)); }); - - const MAX_CHARS = 50_000; - - // serial settings (same as SerialConsole) - const [serialSettings, setSerialSettings] = useState({ + // extension config (buttons + prefs) + const [buttonConfig, setButtonConfig] = useState({ baudRate: "9600", dataBits: "8", stopBits: "1", parity: "none", + terminator: {label: "CR (\\r)", value: "\r"}, + lineMode: true, + hideSerialSettings: false, + enableEcho: false, + buttons: [], }); - // extension config (buttons + prefs) - const [buttonConfig, setButtonConfig] = useState({ - buttons: [], - terminator: "", - hideSerialSettings: false, - hideSerialResponse: true, -}); - // editor modal state const [editorOpen, setEditorOpen] = useState(null); const [draftLabel, setDraftLabel] = useState(""); const [draftCmd, setDraftCmd] = useState(""); - const [serialResponse, setSerialResponse] = useState(""); - const [paused, setPaused] = useState(false); - const taRef = useRef(null); + const [draftTerminator, setDraftTerminator] = useState({label: "CR (\\r)", value: "\r"}); // load serial settings like SerialConsole useEffect(() => { - send("getSerialSettings", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - `Failed to get serial settings: ${resp.error.data || "Unknown error"}`, - ); - return; - } - setSerialSettings(resp.result as SerialSettings); - }); - send("getSerialButtonConfig", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( @@ -111,57 +98,35 @@ export function SerialButtons() { return; } - setButtonConfig(resp.result as ButtonConfig); + setButtonConfig(resp.result as CustomButtonSettings); + setTerminalLineMode((resp.result as CustomButtonSettings).lineMode); }); - }); + }, [send, setTerminalLineMode]); - const handleSerialSettingChange = (setting: keyof SerialSettings, value: string) => { - const newSettings = { ...serialSettings, [setting]: value }; - send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(`Failed to update serial settings: ${resp.error.data || "Unknown error"}`); - return; - } - setSerialSettings(newSettings); - }); - }; - - const handleSerialButtonConfigChange = (config: keyof ButtonConfig, value: unknown) => { + const handleSerialButtonConfigChange = (config: keyof CustomButtonSettings, value: unknown) => { const newButtonConfig = { ...buttonConfig, [config]: value }; send("setSerialButtonConfig", { config: newButtonConfig }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to update button config: ${resp.error.data || "Unknown error"}`); return; } - setButtonConfig(newButtonConfig); }); setButtonConfig(newButtonConfig); }; - useEffect(() => { - if (buttonConfig.hideSerialResponse) return; - const el = taRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [serialResponse, buttonConfig.hideSerialResponse]); - const onClickButton = (btn: QuickButton) => { - /** build final string to send: - * if the user's button command already contains a terminator, we don't append the selected terminator safely - */ - const raw = btn.command; - const t = buttonConfig.terminator ?? ""; - const command = raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t; + const command = btn.command + btn.terminator.value; + const terminator = btn.terminator.value; - send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => { + send("sendCustomCommand", { command, terminator }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( - `Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, + `Failed to send custom command: ${resp.error.data || "Unknown error"}`, ); } }); - }; /** CRUD helpers */ @@ -169,12 +134,14 @@ export function SerialButtons() { setEditorOpen({ id: undefined }); setDraftLabel(""); setDraftCmd(""); + setDraftTerminator({label: "CR (\\r)", value: "\r"}); }; const editBtn = (btn: QuickButton) => { setEditorOpen({ id: btn.id }); setDraftLabel(btn.label); setDraftCmd(btn.command); + setDraftTerminator(btn.terminator); }; const removeBtn = (id: string) => { @@ -227,23 +194,29 @@ export function SerialButtons() { const saveDraft = () => { const label = draftLabel.trim() || "Unnamed"; - const command = draftCmd.trim(); + const command = draftCmd; if (!command) { notifications.error("Command cannot be empty."); return; } + const terminator = draftTerminator; - const isEdit = editorOpen?.id; - const nextButtons = isEdit - ? buttonConfig.buttons.map(b => (b.id === isEdit ? { ...b, label, command } : b)) - : [...buttonConfig.buttons, { id: genId(), label, command, sort: buttonConfig.buttons.length }]; + // if editing, get current id, otherwise undefined => new button + const currentID = editorOpen?.id; + + // either update existing or add new + // if new, assign next sort index + // if existing, keep sort index + const nextButtons = currentID + ? buttonConfig.buttons.map(b => (b.id === currentID ? { ...b, label, command } : b)) + : [...buttonConfig.buttons, { id: genId(), label, command, terminator, sort: buttonConfig.buttons.length }]; handleSerialButtonConfigChange("buttons", stableSort(nextButtons) ); setEditorOpen(null); }; /** simple reordering: alphabetical by sort, then label */ - const sortedButtons = useMemo(() => stableSort(buttonConfig.buttons), [buttonConfig.buttons]); + const sortedButtons = useMemo(() => buttonConfig.buttons, [buttonConfig.buttons]); return (
@@ -257,25 +230,28 @@ export function SerialButtons() { {/* Top actions */}

@@ -296,8 +272,8 @@ export function SerialButtons() { { label: "57600", value: "57600" }, { label: "115200", value: "115200" }, ]} - value={serialSettings.baudRate} - onChange={(e) => handleSerialSettingChange("baudRate", e.target.value)} + value={buttonConfig.baudRate} + onChange={(e) => handleSerialButtonConfigChange("baudRate", e.target.value)} /> handleSerialSettingChange("dataBits", e.target.value)} + value={buttonConfig.dataBits} + onChange={(e) => handleSerialButtonConfigChange("dataBits", e.target.value)} /> handleSerialSettingChange("stopBits", e.target.value)} + value={buttonConfig.stopBits} + onChange={(e) => handleSerialButtonConfigChange("stopBits", e.target.value)} /> handleSerialSettingChange("parity", e.target.value)} + value={buttonConfig.parity} + onChange={(e) => handleSerialButtonConfigChange("parity", e.target.value)} /> +
+ handleSerialButtonConfigChange("terminator", {label: e.target.selectedOptions[0].text, value: e.target.value})} + /> +
+ When sent, the selected line ending ({buttonConfig.terminator.label}) will be appended. +
+
+
+ { + handleSerialButtonConfigChange("lineMode", e.target.value === "line") + setTerminalLineMode(e.target.value === "line"); + }} + /> +
+ {buttonConfig.lineMode + ? "In Line Mode, input is sent when you press Enter in the input field." + : "In Raw Mode, input is sent immediately as you type in the console."} +
+
+
+
+ + { + handleSerialButtonConfigChange("enableEcho", e.target.checked); + }} + /> +
- handleSerialButtonConfigChange("terminator", e.target.value)} - />
)} @@ -382,7 +399,7 @@ export function SerialButtons() {
{editorOpen.id ? "Edit Button" : "New Button"}
-
+
- {buttonConfig.terminator != "" && ( + {draftTerminator.value != "" && (
- The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent. + When sent, the selected line ending ({draftTerminator.label}) will be appended.
)}
-
-
+
+
+
+
{editorOpen.id && ( <>
)} - {/* Serial response (collapsible) */} - {!buttonConfig.hideSerialResponse && ( - <> -
- setSerialResponse(e.target.value)} - placeholder="Will show the response recieved from the serial port." - /> -
-
- - )} @@ -481,10 +491,6 @@ export function SerialButtons() { } /** ============== helpers ============== */ - -function pretty(s: string) { - return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n"); -} function genId() { return "b_" + Math.random().toString(36).slice(2, 10); } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..1a634978 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -69,6 +69,9 @@ export interface UIState { terminalType: AvailableTerminalTypes; setTerminalType: (type: UIState["terminalType"]) => void; + + terminalLineMode: boolean; + setTerminalLineMode: (enabled: boolean) => void; } export const useUiStore = create(set => ({ @@ -96,6 +99,9 @@ export const useUiStore = create(set => ({ isAttachedVirtualKeyboardVisible: true, setAttachedVirtualKeyboardVisibility: (enabled: boolean) => set({ isAttachedVirtualKeyboardVisible: enabled }), + + terminalLineMode: true, + setTerminalLineMode: (enabled: boolean) => set({ terminalLineMode: enabled }), })); export interface RTCState { @@ -465,7 +471,7 @@ export interface KeysDownState { keys: number[]; } -export type USBStates = +export type USBStates = | "configured" | "attached" | "not attached"