mirror of https://github.com/jetkvm/kvm.git
Merge extensions "Serial Console" and "Serial Buttons"
This commit is contained in:
parent
cfd5e7cfab
commit
c07ae51da3
234
jsonrpc.go
234
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"}},
|
||||
}
|
||||
|
|
164
serial.go
164
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 {
|
||||
|
|
|
@ -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<string[]>([]);
|
||||
|
||||
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<number>(-1); // -1 = fresh line
|
||||
const [anchorPrefix, setAnchorPrefix] = useState<string | null>(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<HTMLDivElement>(null);
|
||||
|
||||
// keep selected item in view when sel changes
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
const el = listRef.current.querySelector<HTMLDivElement>(`[data-idx="${sel}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [sel, results]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
className="absolute bottom-12 left-0 right-0 ml-17 mr-8 mb-5 rounded-md border border-slate-600 bg-slate-900/95 p-2 shadow-lg"
|
||||
role="listbox"
|
||||
aria-activedescendant={`rev-opt-${sel}`}
|
||||
>
|
||||
<div ref={listRef} className="max-h-48 overflow-auto">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-2 py-1 text-sm text-slate-400">No matches</div>
|
||||
) : results.map((r, i) => (
|
||||
<div
|
||||
id={`rev-opt-${i}`}
|
||||
data-idx={i}
|
||||
key={`${r.index}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === sel}
|
||||
className={clsx(
|
||||
"px-2 py-1 font-mono text-sm cursor-pointer",
|
||||
i === sel ? "bg-slate-700 text-white rounded" : "text-slate-200",
|
||||
)}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => onPick(r.value)}
|
||||
>
|
||||
{r.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-s text-slate-400">
|
||||
<span>↑/↓ select • Enter accept • Esc close</span>
|
||||
<button className="underline" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 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<HTMLInputElement>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={clsx("relative", className)}>
|
||||
<div className="flex items-center gap-2" style={{visibility: revOpen ? "hidden" : "unset"} }>
|
||||
<span className="text-xs text-slate-400 select-none">CMD</span>
|
||||
<InputField
|
||||
ref={cmdInputRef}
|
||||
size="MD"
|
||||
disabled={disabled}
|
||||
value={cmd}
|
||||
onChange={(e) => { setCmd(e.target.value); resetTraversal(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reverse search controls */}
|
||||
{revOpen && (
|
||||
<div className="mt-[-40px]">
|
||||
<div className="flex items-center gap-2 bg-[#0f172a]">
|
||||
<span className="text-s text-slate-400 select-none">Search</span>
|
||||
<InputField
|
||||
size="MD"
|
||||
autoFocus
|
||||
value={revQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<ReverseSearch
|
||||
open={revOpen}
|
||||
results={results}
|
||||
sel={sel}
|
||||
setSel={setSel}
|
||||
onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }}
|
||||
onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandInput;
|
|
@ -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 (
|
||||
<div
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
|
@ -199,7 +213,14 @@ function Terminal({
|
|||
</div>
|
||||
|
||||
<div className="h-[calc(100%-36px)] p-3">
|
||||
<div ref={ref} style={{ height: "100%", width: "100%" }} />
|
||||
<div key="serial" ref={ref} style={{height: (terminalType === "serial" && terminalLineMode) ? "90%" : "100%", width: "100%" }} />
|
||||
{terminalType == "serial" && terminalLineMode && (
|
||||
<CommandInput
|
||||
placeholder="Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)"
|
||||
onSend={sendLine}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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<SerialSettings>({
|
||||
// extension config (buttons + prefs)
|
||||
const [buttonConfig, setButtonConfig] = useState<CustomButtonSettings>({
|
||||
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<ButtonConfig>({
|
||||
buttons: [],
|
||||
terminator: "",
|
||||
hideSerialSettings: false,
|
||||
hideSerialResponse: true,
|
||||
});
|
||||
|
||||
// editor modal state
|
||||
const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null);
|
||||
const [draftLabel, setDraftLabel] = useState("");
|
||||
const [draftCmd, setDraftCmd] = useState("");
|
||||
const [serialResponse, setSerialResponse] = useState("");
|
||||
const [paused, setPaused] = useState(false);
|
||||
const taRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<div className="space-y-4">
|
||||
|
@ -257,25 +230,28 @@ export function SerialButtons() {
|
|||
{/* Top actions */}
|
||||
<div className="flex flex-wrap justify-around items-center gap-3">
|
||||
<Button
|
||||
size="SM"
|
||||
size="XS"
|
||||
theme="primary"
|
||||
LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff}
|
||||
text={buttonConfig.hideSerialSettings ? "Show Settings" : "Hide Settings"}
|
||||
onClick={() => handleSerialButtonConfigChange("hideSerialSettings", !buttonConfig.hideSerialSettings )}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
size="XS"
|
||||
theme="primary"
|
||||
LeadingIcon={LuPlus}
|
||||
text="Add Button"
|
||||
onClick={addNew}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
size="XS"
|
||||
theme="primary"
|
||||
LeadingIcon={buttonConfig.hideSerialResponse ? LuEye : LuEyeOff}
|
||||
text={buttonConfig.hideSerialResponse ? "View RX" : "Hide RX"}
|
||||
onClick={() => handleSerialButtonConfigChange("hideSerialResponse", !buttonConfig.hideSerialResponse )}
|
||||
LeadingIcon={LuTerminal}
|
||||
text="Open Console"
|
||||
onClick={() => {
|
||||
setTerminalType("serial");
|
||||
console.log("Opening serial console with settings: ", buttonConfig);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
|
@ -306,8 +282,8 @@ export function SerialButtons() {
|
|||
{ label: "8", value: "8" },
|
||||
{ label: "7", value: "7" },
|
||||
]}
|
||||
value={serialSettings.dataBits}
|
||||
onChange={(e) => handleSerialSettingChange("dataBits", e.target.value)}
|
||||
value={buttonConfig.dataBits}
|
||||
onChange={(e) => handleSerialButtonConfigChange("dataBits", e.target.value)}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
|
@ -317,8 +293,8 @@ export function SerialButtons() {
|
|||
{ label: "1.5", value: "1.5" },
|
||||
{ label: "2", value: "2" },
|
||||
]}
|
||||
value={serialSettings.stopBits}
|
||||
onChange={(e) => handleSerialSettingChange("stopBits", e.target.value)}
|
||||
value={buttonConfig.stopBits}
|
||||
onChange={(e) => handleSerialButtonConfigChange("stopBits", e.target.value)}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
|
@ -328,20 +304,61 @@ export function SerialButtons() {
|
|||
{ label: "Even", value: "even" },
|
||||
{ label: "Odd", value: "odd" },
|
||||
]}
|
||||
value={serialSettings.parity}
|
||||
onChange={(e) => handleSerialSettingChange("parity", e.target.value)}
|
||||
value={buttonConfig.parity}
|
||||
onChange={(e) => handleSerialButtonConfigChange("parity", e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label="Line ending"
|
||||
options={[
|
||||
{ label: "None", value: "" },
|
||||
{ label: "CR (\\r)", value: "\r" },
|
||||
{ label: "LF (\\n)", value: "\n" },
|
||||
{ label: "CRLF (\\r\\n)", value: "\r\n" },
|
||||
{ label: "LFCR (\\n\\r)", value: "\n\r" },
|
||||
]}
|
||||
value={buttonConfig.terminator.value}
|
||||
onChange={(e) => handleSerialButtonConfigChange("terminator", {label: e.target.selectedOptions[0].text, value: e.target.value})}
|
||||
/>
|
||||
<div className="text-xs text-white opacity-70 mt-0 ml-2">
|
||||
When sent, the selected line ending ({buttonConfig.terminator.label}) will be appended.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label="Terminal Mode"
|
||||
options={[
|
||||
{ label: "Raw Mode", value: "raw" },
|
||||
{ label: "Line Mode", value: "line" },
|
||||
]}
|
||||
value={buttonConfig.lineMode ? "line" : "raw"}
|
||||
onChange={(e) => {
|
||||
handleSerialButtonConfigChange("lineMode", e.target.value === "line")
|
||||
setTerminalLineMode(e.target.value === "line");
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-white opacity-70 mt-0 ml-2">
|
||||
{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."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 m-2">
|
||||
<SettingsItem
|
||||
title="Local Echo"
|
||||
description="Whether to echo received characters back to the sender"
|
||||
>
|
||||
<Checkbox
|
||||
checked={buttonConfig.enableEcho}
|
||||
onChange={e => {
|
||||
handleSerialButtonConfigChange("enableEcho", e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<SelectMenuBasic
|
||||
label="Line ending"
|
||||
options={[
|
||||
{ label: "None", value: "" },
|
||||
{ label: "CR (\\r)", value: "\r" },
|
||||
{ label: "CRLF (\\r\\n)", value: "\r\n" },
|
||||
]}
|
||||
value={buttonConfig.terminator}
|
||||
onChange={(e) => handleSerialButtonConfigChange("terminator", e.target.value)}
|
||||
/>
|
||||
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||
</>
|
||||
)}
|
||||
|
@ -382,7 +399,7 @@ export function SerialButtons() {
|
|||
<LuSettings2 className="h-3.5 text-white shrink-0 justify-start" />
|
||||
<div className="font-medium text-black dark:text-white">{editorOpen.id ? "Edit Button" : "New Button"}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-23">
|
||||
<div>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
|
@ -406,16 +423,34 @@ export function SerialButtons() {
|
|||
setDraftCmd(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{buttonConfig.terminator != "" && (
|
||||
{draftTerminator.value != "" && (
|
||||
<div className="text-xs text-white opacity-70 mt-1">
|
||||
The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent.
|
||||
When sent, the selected line ending ({draftTerminator.label}) will be appended.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="SM" theme="primary" LeadingIcon={LuSave} text="Save" onClick={saveDraft} />
|
||||
<Button size="SM" theme="primary" text="Cancel" onClick={() => setEditorOpen(null)} />
|
||||
<div className="flex justify-around items-end">
|
||||
<SelectMenuBasic
|
||||
label="Line ending"
|
||||
options={[
|
||||
{ label: "None", value: "" },
|
||||
{ label: "CR (\\r)", value: "\r" },
|
||||
{ label: "LF (\\n)", value: "\n" },
|
||||
{ label: "CRLF (\\r\\n)", value: "\r\n" },
|
||||
{ label: "LFCR (\\n\\r)", value: "\n\r" },
|
||||
]}
|
||||
value={draftTerminator.value}
|
||||
onChange={(e) => setDraftTerminator({label: e.target.selectedOptions[0].text, value: e.target.value})}
|
||||
/>
|
||||
<div className="pb-[3px]">
|
||||
<Button size="SM" theme="primary" LeadingIcon={LuSave} text="Save" onClick={saveDraft} />
|
||||
</div>
|
||||
<div className="pb-[3px]">
|
||||
<Button size="SM" theme="primary" LeadingIcon={LuCircleX} text="Cancel" onClick={() => setEditorOpen(null)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-around mt-3">
|
||||
{editorOpen.id && (
|
||||
<>
|
||||
<Button
|
||||
|
@ -430,12 +465,18 @@ export function SerialButtons() {
|
|||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuArrowBigUp}
|
||||
text="Move Up"
|
||||
aria-label={`Move ${draftLabel} up`}
|
||||
disabled={sortedButtons.findIndex(b => b.id === editorOpen.id) === 0}
|
||||
onClick={() => moveUpBtn(editorOpen.id!)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuArrowBigDown}
|
||||
text="Move Down"
|
||||
aria-label={`Move ${draftLabel} down`}
|
||||
disabled={sortedButtons.findIndex(b => b.id === editorOpen.id)+1 === sortedButtons.length}
|
||||
onClick={() => moveDownBtn(editorOpen.id!)}
|
||||
/>
|
||||
</>
|
||||
|
@ -443,37 +484,6 @@ export function SerialButtons() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Serial response (collapsible) */}
|
||||
{!buttonConfig.hideSerialResponse && (
|
||||
<>
|
||||
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||
<TextAreaWithLabel
|
||||
ref={taRef}
|
||||
readOnly
|
||||
label="RX response from serial connection"
|
||||
value={serialResponse|| ""}
|
||||
rows={3}
|
||||
onChange={e => setSerialResponse(e.target.value)}
|
||||
placeholder="Will show the response recieved from the serial port."
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="primary"
|
||||
text={paused ? "Resume" : "Pause"}
|
||||
LeadingIcon={paused ? LuCirclePlay : LuCirclePause}
|
||||
onClick={() => setPaused(p => !p)}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="primary"
|
||||
text="Clear"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={() => setSerialResponse("")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -69,6 +69,9 @@ export interface UIState {
|
|||
|
||||
terminalType: AvailableTerminalTypes;
|
||||
setTerminalType: (type: UIState["terminalType"]) => void;
|
||||
|
||||
terminalLineMode: boolean;
|
||||
setTerminalLineMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UIState>(set => ({
|
||||
|
@ -96,6 +99,9 @@ export const useUiStore = create<UIState>(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"
|
||||
|
|
Loading…
Reference in New Issue