Update backend, implement pause function in terminal

This commit is contained in:
Severin Müller 2025-10-09 06:32:40 +02:00
parent 4ddce3f0ee
commit 3b14267155
9 changed files with 1004 additions and 927 deletions

View File

@ -10,13 +10,11 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv"
"sync" "sync"
"time" "time"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc" "github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
@ -829,103 +827,12 @@ func rpcSendCustomCommand(command string) error {
return nil return nil
} }
type SerialSettings struct {
BaudRate string `json:"baudRate"`
DataBits string `json:"dataBits"`
StopBits string `json:"stopBits"`
Parity string `json:"parity"`
}
func rpcGetSerialSettings() (SerialSettings, error) { func rpcGetSerialSettings() (SerialSettings, error) {
settings := SerialSettings{
BaudRate: strconv.Itoa(serialPortMode.BaudRate),
DataBits: strconv.Itoa(serialPortMode.DataBits),
StopBits: "1",
Parity: "none",
}
switch serialPortMode.StopBits {
case serial.OneStopBit:
settings.StopBits = "1"
case serial.OnePointFiveStopBits:
settings.StopBits = "1.5"
case serial.TwoStopBits:
settings.StopBits = "2"
}
switch serialPortMode.Parity {
case serial.NoParity:
settings.Parity = "none"
case serial.OddParity:
settings.Parity = "odd"
case serial.EvenParity:
settings.Parity = "even"
case serial.MarkParity:
settings.Parity = "mark"
case serial.SpaceParity:
settings.Parity = "space"
}
return settings, nil
}
var serialPortMode = defaultMode
func rpcSetSerialSettings(settings SerialSettings) error {
baudRate, err := strconv.Atoi(settings.BaudRate)
if err != nil {
return fmt.Errorf("invalid baud rate: %v", err)
}
dataBits, err := strconv.Atoi(settings.DataBits)
if err != nil {
return fmt.Errorf("invalid data bits: %v", err)
}
var stopBits serial.StopBits
switch settings.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", settings.StopBits)
}
var parity serial.Parity
switch settings.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", settings.Parity)
}
serialPortMode = &serial.Mode{
BaudRate: baudRate,
DataBits: dataBits,
StopBits: stopBits,
Parity: parity,
}
_ = port.SetMode(serialPortMode)
return nil
}
func rpcGetSerialButtonConfig() (CustomButtonSettings, error) {
return getSerialSettings() return getSerialSettings()
} }
func rpcSetSerialButtonConfig(config CustomButtonSettings) error { func rpcSetSerialSettings(settings SerialSettings) error {
return setSerialSettings(config) return setSerialSettings(settings)
} }
const SerialCommandHistoryPath = "/userdata/serialCommandHistory.json" const SerialCommandHistoryPath = "/userdata/serialCommandHistory.json"
@ -968,6 +875,30 @@ func rpcSetSerialCommandHistory(commandHistory []string) error {
return nil return nil
} }
func rpcDeleteSerialCommandHistory() error {
logger.Trace().Str("path", SerialCommandHistoryPath).Msg("Deleting serial command history")
empty := []string{}
file, err := os.Create(SerialCommandHistoryPath)
if err != nil {
return fmt.Errorf("failed to create SerialCommandHistory file: %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(empty); err != nil {
return fmt.Errorf("failed to encode SerialCommandHistory: %w", err)
}
return nil
}
func rpcSetTerminalPaused(terminalPaused bool) error {
setTerminalPaused(terminalPaused)
return nil
}
func rpcGetUsbDevices() (usbgadget.Devices, error) { func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil return *config.UsbDevices, nil
} }
@ -1243,94 +1174,94 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro
} }
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}}, "reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID}, "getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice}, "deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState}, "getCloudState": {Func: rpcGetCloudState},
"getNetworkState": {Func: rpcGetNetworkState}, "getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings}, "getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"getKeyDownState": {Func: rpcGetKeysDownState}, "getKeyDownState": {Func: rpcGetKeysDownState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState}, "getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState}, "getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage}, "unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, "rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState}, "getJigglerState": {Func: rpcGetJigglerState},
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, "setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
"getJigglerConfig": {Func: rpcGetJigglerConfig}, "getJigglerConfig": {Func: rpcGetJigglerConfig},
"getTimezones": {Func: rpcGetTimezones}, "getTimezones": {Func: rpcGetTimezones},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState}, "getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}}, "setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID}, "getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getVideoLogStatus": {Func: rpcGetVideoLogStatus}, "getVideoLogStatus": {Func: rpcGetVideoLogStatus},
"getDevChannelState": {Func: rpcGetDevChannelState}, "getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion}, "getLocalVersion": {Func: rpcGetLocalVersion},
"getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate}, "tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState}, "getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState}, "getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, "setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState}, "getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}}, "setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, "setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode}, "getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending}, "isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState}, "getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig}, "getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState}, "getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace}, "getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles}, "listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}}, "deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}}, "startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig}, "resetConfig": {Func: rpcResetConfig},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}}, "setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation}, "getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings}, "getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState}, "getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}}, "setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension}, "getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState}, "getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}}, "sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}},
"getSerialSettings": {Func: rpcGetSerialSettings}, "getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getSerialButtonConfig": {Func: rpcGetSerialButtonConfig}, "getSerialCommandHistory": {Func: rpcGetSerialCommandHistory},
"setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}}, "setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}},
"getSerialCommandHistory": {Func: rpcGetSerialCommandHistory}, "deleteSerialCommandHistory": {Func: rpcDeleteSerialCommandHistory},
"setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}}, "setTerminalPaused": {Func: rpcSetTerminalPaused, Params: []string{"terminalPaused"}},
"getUsbDevices": {Func: rpcGetUsbDevices}, "getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout}, "getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros}, "getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
} }

301
serial.go
View File

@ -2,10 +2,8 @@ package kvm
import ( import (
"bufio" "bufio"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -19,7 +17,7 @@ const serialPortPath = "/dev/ttyS3"
var port serial.Port var port serial.Port
var serialMux *SerialMux var serialMux *SerialMux
var consoleBr *ConsoleBroker var consoleBroker *ConsoleBroker
func mountATXControl() error { func mountATXControl() error {
_ = port.SetMode(defaultMode) _ = port.SetMode(defaultMode)
@ -267,69 +265,15 @@ func unmountSerialButtons() error {
return nil return nil
} }
// ---- Serial Buttons RX fan-out (JSON-RPC events) ----
var serialButtonsRXStopCh chan struct{}
func startSerialButtonsRxLoop(session *Session) {
scopedLogger := serialLogger.With().Str("service", "custom_buttons_rx").Logger()
scopedLogger.Debug().Msg("Attempting to start RX reader.")
// Stop previous loop if running
if serialButtonsRXStopCh != nil {
stopSerialButtonsRxLoop()
}
serialButtonsRXStopCh = make(chan struct{})
go func() {
buf := make([]byte, 4096)
scopedLogger.Debug().Msg("Starting loop")
for {
select {
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 {
scopedLogger.Debug().Err(err).Msg("serial RX read error")
}
time.Sleep(50 * time.Millisecond)
continue
}
if n == 0 {
continue
}
// Safe for any bytes: wrap in Base64
b64 := base64.StdEncoding.EncodeToString(buf[:n])
writeJSONRPCEvent("serial.rx", map[string]any{
"base64": b64,
}, currentSession)
}
}
}()
}
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) error {
scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger() scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger()
scopedLogger.Info().Str("Command", command).Msg("Sending custom command.") scopedLogger.Info().Str("Command", command).Msg("Sending custom command.")
_, err := port.Write([]byte(command)) scopedLogger.Info().Msgf("Sending custom command: %q", command)
if err != nil { if serialMux == nil {
scopedLogger.Warn().Err(err).Str("Command", command).Msg("Failed to send serial command") return fmt.Errorf("serial mux not initialized")
return err
} }
payload := []byte(command)
serialMux.Enqueue(payload, "button", true) // echo if enabled
return nil return nil
} }
@ -340,15 +284,19 @@ var defaultMode = &serial.Mode{
StopBits: serial.OneStopBit, StopBits: serial.OneStopBit,
} }
var SerialConfig = CustomButtonSettings{ var serialPortMode = defaultMode
var serialConfig = SerialSettings{
BaudRate: defaultMode.BaudRate, BaudRate: defaultMode.BaudRate,
DataBits: defaultMode.DataBits, DataBits: defaultMode.DataBits,
Parity: "none", Parity: "none",
StopBits: "1", StopBits: "1",
Terminator: Terminator{Label: "CR (\\r)", Value: "\r"}, Terminator: Terminator{Label: "LF (\\n)", Value: "\n"},
LineMode: true,
HideSerialSettings: false, HideSerialSettings: false,
EnableEcho: false, EnableEcho: false,
NormalizeMode: "names",
NormalizeLineEnd: "keep",
PreserveANSI: true,
Buttons: []QuickButton{}, Buttons: []QuickButton{},
} }
@ -368,62 +316,137 @@ type QuickButton struct {
} }
// Mode describes a serial port configuration. // Mode describes a serial port configuration.
type CustomButtonSettings struct { type SerialSettings struct {
BaudRate int `json:"baudRate"` // The serial port bitrate (aka Baudrate) BaudRate int `json:"baudRate"` // The serial port bitrate (aka Baudrate)
DataBits int `json:"dataBits"` // Size of the character (must be 5, 6, 7 or 8) DataBits int `json:"dataBits"` // Size of the character (must be 5, 6, 7 or 8)
Parity string `json:"parity"` // Parity (see Parity type for more info) Parity string `json:"parity"` // Parity (see Parity type for more info)
StopBits string `json:"stopBits"` // Stop bits (see StopBits 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 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 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 EnableEcho bool `json:"enableEcho"` // Whether to echo received characters back to the sender
NormalizeMode string `json:"normalizeMode"` // Normalization mode: "carret", "names", "hex"
NormalizeLineEnd string `json:"normalizeLineEnd"` // Line ending normalization: "keep", "lf", "cr", "crlf"
PreserveANSI bool `json:"preserveANSI"` // Whether to preserve ANSI escape codes
Buttons []QuickButton `json:"buttons"` // Custom quick buttons Buttons []QuickButton `json:"buttons"` // Custom quick buttons
} }
func getSerialSettings() (CustomButtonSettings, error) { func getSerialSettings() (SerialSettings, error) {
switch defaultMode.StopBits { switch defaultMode.StopBits {
case serial.OneStopBit: case serial.OneStopBit:
SerialConfig.StopBits = "1" serialConfig.StopBits = "1"
case serial.OnePointFiveStopBits: case serial.OnePointFiveStopBits:
SerialConfig.StopBits = "1.5" serialConfig.StopBits = "1.5"
case serial.TwoStopBits: case serial.TwoStopBits:
SerialConfig.StopBits = "2" serialConfig.StopBits = "2"
} }
switch defaultMode.Parity { switch defaultMode.Parity {
case serial.NoParity: case serial.NoParity:
SerialConfig.Parity = "none" serialConfig.Parity = "none"
case serial.OddParity: case serial.OddParity:
SerialConfig.Parity = "odd" serialConfig.Parity = "odd"
case serial.EvenParity: case serial.EvenParity:
SerialConfig.Parity = "even" serialConfig.Parity = "even"
case serial.MarkParity: case serial.MarkParity:
SerialConfig.Parity = "mark" serialConfig.Parity = "mark"
case serial.SpaceParity: case serial.SpaceParity:
SerialConfig.Parity = "space" serialConfig.Parity = "space"
} }
file, err := os.Open(serialSettingsPath) file, err := os.Open(serialSettingsPath)
if err != nil { if err != nil {
logger.Debug().Msg("SerialButtons config file doesn't exist, using default") logger.Debug().Msg("SerialButtons config file doesn't exist, using default")
return SerialConfig, err return serialConfig, err
} }
defer file.Close() defer file.Close()
// load and merge the default config with the user config // load and merge the default config with the user config
var loadedConfig CustomButtonSettings var loadedConfig SerialSettings
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed") logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed")
return SerialConfig, nil return serialConfig, nil
} }
SerialConfig = loadedConfig // Update global config serialConfig = loadedConfig // Update global config
// Apply settings to serial port, when opening the extension
var stopBits serial.StopBits
switch serialConfig.StopBits {
case "1":
stopBits = serial.OneStopBit
case "1.5":
stopBits = serial.OnePointFiveStopBits
case "2":
stopBits = serial.TwoStopBits
}
var parity serial.Parity
switch serialConfig.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
}
serialPortMode = &serial.Mode{
BaudRate: serialConfig.BaudRate,
DataBits: serialConfig.DataBits,
StopBits: stopBits,
Parity: parity,
}
_ = port.SetMode(serialPortMode)
if serialMux != nil {
serialMux.SetEchoEnabled(serialConfig.EnableEcho)
}
var normalizeMode NormalizeMode
switch serialConfig.NormalizeMode {
case "carret":
normalizeMode = ModeCaret
case "names":
normalizeMode = ModeNames
case "hex":
normalizeMode = ModeHex
default:
normalizeMode = ModeNames
}
var crlfMode CRLFMode
switch serialConfig.NormalizeLineEnd {
case "keep":
crlfMode = CRLFAsIs
case "lf":
crlfMode = CRLF_LF
case "cr":
crlfMode = CRLF_CR
case "crlf":
crlfMode = CRLF_CRLF
case "lfcr":
crlfMode = CRLF_LFCR
default:
crlfMode = CRLFAsIs
}
if consoleBroker != nil {
norm := NormOptions{
Mode: normalizeMode, CRLF: crlfMode, TabRender: "", PreserveANSI: serialConfig.PreserveANSI,
}
consoleBroker.SetNormOptions(norm)
}
return loadedConfig, nil return loadedConfig, nil
} }
func setSerialSettings(newSettings CustomButtonSettings) error { func setSerialSettings(newSettings SerialSettings) error {
logger.Trace().Str("path", serialSettingsPath).Msg("Saving config") logger.Trace().Str("path", serialSettingsPath).Msg("Saving config")
file, err := os.Create(serialSettingsPath) file, err := os.Create(serialSettingsPath)
@ -474,15 +497,56 @@ func setSerialSettings(newSettings CustomButtonSettings) error {
_ = port.SetMode(serialPortMode) _ = port.SetMode(serialPortMode)
SerialConfig = newSettings // Update global config serialConfig = newSettings // Update global config
if serialMux != nil { if serialMux != nil {
serialMux.SetEchoEnabled(SerialConfig.EnableEcho) serialMux.SetEchoEnabled(serialConfig.EnableEcho)
}
var normalizeMode NormalizeMode
switch serialConfig.NormalizeMode {
case "carret":
normalizeMode = ModeCaret
case "names":
normalizeMode = ModeNames
case "hex":
normalizeMode = ModeHex
default:
normalizeMode = ModeNames
}
var crlfMode CRLFMode
switch serialConfig.NormalizeLineEnd {
case "keep":
crlfMode = CRLFAsIs
case "lf":
crlfMode = CRLF_LF
case "cr":
crlfMode = CRLF_CR
case "crlf":
crlfMode = CRLF_CRLF
case "lfcr":
crlfMode = CRLF_LFCR
default:
crlfMode = CRLFAsIs
}
if consoleBroker != nil {
norm := NormOptions{
Mode: normalizeMode, CRLF: crlfMode, TabRender: "", PreserveANSI: serialConfig.PreserveANSI,
}
consoleBroker.SetNormOptions(norm)
} }
return nil return nil
} }
func setTerminalPaused(paused bool) {
if consoleBroker != nil {
consoleBroker.SetTerminalPaused(paused)
}
}
func initSerialPort() { func initSerialPort() {
_ = reopenSerialPort() _ = reopenSerialPort()
switch config.ActiveExtension { switch config.ActiveExtension {
@ -510,80 +574,61 @@ func reopenSerialPort() error {
// new broker (no sink yet—set it in handleSerialChannel.OnOpen) // new broker (no sink yet—set it in handleSerialChannel.OnOpen)
norm := NormOptions{ norm := NormOptions{
Mode: ModeCaret, CRLF: CRLF_CRLF, TabRender: "", PreserveANSI: true, Mode: ModeNames, CRLF: CRLF_LF, TabRender: "", PreserveANSI: true,
} }
if consoleBr != nil { if consoleBroker != nil {
consoleBr.Close() consoleBroker.Close()
} }
consoleBr = NewConsoleBroker(nil, norm) consoleBroker = NewConsoleBroker(nil, norm)
consoleBr.Start() consoleBroker.Start()
// new mux // new mux
if serialMux != nil { if serialMux != nil {
serialMux.Close() serialMux.Close()
} }
serialMux = NewSerialMux(port, consoleBr) serialMux = NewSerialMux(port, consoleBroker)
serialMux.SetEchoEnabled(SerialConfig.EnableEcho) // honor your setting serialMux.SetEchoEnabled(serialConfig.EnableEcho) // honor your setting
serialMux.Start() serialMux.Start()
serialMux.SetEchoEnabled(SerialConfig.EnableEcho)
return nil return nil
} }
func handleSerialChannel(d *webrtc.DataChannel) { func handleSerialChannel(dataChannel *webrtc.DataChannel) {
scopedLogger := serialLogger.With(). scopedLogger := serialLogger.With().
Uint16("data_channel_id", *d.ID()).Logger() Uint16("data_channel_id", *dataChannel.ID()).Str("service", "serial terminal channel").Logger()
dataChannel.OnOpen(func() {
d.OnOpen(func() {
// go func() {
// buf := make([]byte, 1024)
// for {
// n, err := port.Read(buf)
// if err != nil {
// if err != io.EOF {
// scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
// }
// break
// }
// err = d.Send(buf[:n])
// if err != nil {
// scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
// break
// }
// }
// }()
// Plug the terminal sink into the broker // Plug the terminal sink into the broker
if consoleBr != nil { scopedLogger.Info().Msg("Opening serial channel from console broker")
consoleBr.SetSink(dataChannelSink{d: d}) if consoleBroker != nil {
_ = d.SendText("RX: [serial attached]\r\n") consoleBroker.SetSink(dataChannelSink{dataChannel: dataChannel})
_ = dataChannel.SendText("RX: [serial attached]\n")
scopedLogger.Info().Msg("Serial channel is now active")
} }
}) })
d.OnMessage(func(msg webrtc.DataChannelMessage) { dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
// if port == nil {
// return scopedLogger.Info().Bytes("Data:", msg.Data).Msg("Sending data to serial mux")
// } scopedLogger.Info().Msgf("Sending data to serial mux: %q", msg.Data)
// _, err := port.Write(append(msg.Data, []byte(SerialConfig.Terminator.Value)...))
// if err != nil {
// scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
// }
if serialMux == nil { if serialMux == nil {
return return
} }
payload := append(msg.Data, []byte(SerialConfig.Terminator.Value)...)
// requestEcho=true — the mux will honor it only if EnableEcho is on // requestEcho=true — the mux will honor it only if EnableEcho is on
serialMux.Enqueue(payload, "webrtc", true) serialMux.Enqueue(msg.Data, "webrtc", true)
}) })
d.OnError(func(err error) { dataChannel.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Serial channel error") scopedLogger.Warn().Err(err).Msg("Serial channel error")
}) })
d.OnClose(func() { dataChannel.OnClose(func() {
scopedLogger.Info().Msg("Serial channel closed") scopedLogger.Info().Msg("Serial channel closed")
if consoleBr != nil { if consoleBroker != nil {
consoleBr.SetSink(nil) consoleBroker.SetSink(nil)
} }
}) })
} }

View File

@ -17,9 +17,9 @@ type Sink interface {
SendText(s string) error SendText(s string) error
} }
type dataChannelSink struct{ d *webrtc.DataChannel } type dataChannelSink struct{ dataChannel *webrtc.DataChannel }
func (s dataChannelSink) SendText(str string) error { return s.d.SendText(str) } func (sink dataChannelSink) SendText(str string) error { return sink.dataChannel.SendText(str) }
/* ---------- NORMALIZATION (applies to RX & TX) ---------- */ /* ---------- NORMALIZATION (applies to RX & TX) ---------- */
@ -35,9 +35,10 @@ type CRLFMode int
const ( const (
CRLFAsIs CRLFMode = iota CRLFAsIs CRLFMode = iota
CRLF_CRLF
CRLF_LF CRLF_LF
CRLF_CR CRLF_CR
CRLF_CRLF
CRLF_LFCR
) )
type NormOptions struct { type NormOptions struct {
@ -93,14 +94,6 @@ func normalize(in []byte, opt NormOptions) string {
case CRLFAsIs: case CRLFAsIs:
out.WriteByte(b) out.WriteByte(b)
i++ i++
case CRLF_CRLF:
if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) {
out.WriteString("\r\n")
i += 2
} else {
out.WriteString("\r\n")
i++
}
case CRLF_LF: case CRLF_LF:
if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) { if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) {
i += 2 i += 2
@ -115,6 +108,22 @@ func normalize(in []byte, opt NormOptions) string {
i++ i++
} }
out.WriteByte('\r') out.WriteByte('\r')
case CRLF_CRLF:
if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) {
out.WriteString("\n")
i += 2
} else {
out.WriteString("\n")
i++
}
case CRLF_LFCR:
if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) {
out.WriteString("\r")
i += 2
} else {
out.WriteString("\r")
i++
}
} }
continue continue
} }
@ -184,11 +193,22 @@ type ConsoleBroker struct {
in chan consoleEvent in chan consoleEvent
done chan struct{} done chan struct{}
// pause control
terminalPaused bool
pauseCh chan bool
// buffered output while paused
bufLines []string
bufBytes int
maxBufLines int
maxBufBytes int
// line-aware echo // line-aware echo
rxAtLineEnd bool rxAtLineEnd bool
pendingTX *consoleEvent txLineActive bool // true if were mid-line (prefix already written)
quietTimer *time.Timer pendingTX *consoleEvent
quietAfter time.Duration quietTimer *time.Timer
quietAfter time.Duration
// normalization // normalization
norm NormOptions norm NormOptions
@ -200,42 +220,78 @@ type ConsoleBroker struct {
func NewConsoleBroker(s Sink, norm NormOptions) *ConsoleBroker { func NewConsoleBroker(s Sink, norm NormOptions) *ConsoleBroker {
return &ConsoleBroker{ return &ConsoleBroker{
sink: s, sink: s,
in: make(chan consoleEvent, 256), in: make(chan consoleEvent, 256),
done: make(chan struct{}), done: make(chan struct{}),
rxAtLineEnd: true, pauseCh: make(chan bool, 8),
quietAfter: 120 * time.Millisecond, terminalPaused: false,
norm: norm, rxAtLineEnd: true,
labelRX: "RX", txLineActive: false,
labelTX: "TX", quietAfter: 120 * time.Millisecond,
norm: norm,
labelRX: "RX",
labelTX: "TX",
// reasonable defaults; tweak as you like
maxBufLines: 5000,
maxBufBytes: 1 << 20, // 1 MiB
} }
} }
func (b *ConsoleBroker) Start() { go b.loop() } func (b *ConsoleBroker) Start() { go b.loop() }
func (b *ConsoleBroker) Close() { close(b.done) } func (b *ConsoleBroker) Close() { close(b.done) }
func (b *ConsoleBroker) SetSink(s Sink) { b.sink = s } func (b *ConsoleBroker) SetSink(s Sink) { b.sink = s }
func (b *ConsoleBroker) SetNormOptions(norm NormOptions) { b.norm = norm }
func (b *ConsoleBroker) SetTerminalPaused(v bool) {
if b == nil {
return
}
// send to broker loop to avoid data races
select {
case b.pauseCh <- v:
default:
b.pauseCh <- v
}
}
func (b *ConsoleBroker) Enqueue(ev consoleEvent) { func (b *ConsoleBroker) Enqueue(ev consoleEvent) {
b.in <- ev // blocking is fine; adjust if you want drop semantics b.in <- ev // blocking is fine; adjust if you want drop semantics
} }
func (b *ConsoleBroker) loop() { func (b *ConsoleBroker) loop() {
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker").Logger()
for { for {
select { select {
case <-b.done: case <-b.done:
return return
case v := <-b.pauseCh:
// apply pause state
was := b.terminalPaused
b.terminalPaused = v
if was && !v {
// we just unpaused: flush buffered output in order
scopedLogger.Info().Msg("Terminal unpaused; flushing buffered output")
b.flushBuffer()
} else if !was && v {
scopedLogger.Info().Msg("Terminal paused; buffering output")
}
case ev := <-b.in: case ev := <-b.in:
switch ev.kind { switch ev.kind {
case evRX: case evRX:
scopedLogger.Info().Msg("Processing RX data from serial port")
b.handleRX(ev.data) b.handleRX(ev.data)
case evTX: case evTX:
scopedLogger.Info().Msg("Processing TX echo request")
b.handleTX(ev.data) b.handleTX(ev.data)
} }
case <-b.quietCh(): case <-b.quietCh():
if b.pendingTX != nil { if b.pendingTX != nil {
_ = b.sink.SendText("\r\n") b.emitToTerminal(b.lineSep()) // use CRLF policy
b.flushPendingTX() b.flushPendingTX()
b.rxAtLineEnd = true b.rxAtLineEnd = true
b.txLineActive = false
} }
} }
} }
@ -268,12 +324,14 @@ func (b *ConsoleBroker) stopQuietTimer() {
} }
func (b *ConsoleBroker) handleRX(data []byte) { func (b *ConsoleBroker) handleRX(data []byte) {
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker RX handler").Logger()
if b.sink == nil || len(data) == 0 { if b.sink == nil || len(data) == 0 {
return return
} }
text := normalize(data, b.norm) text := normalize(data, b.norm)
if text != "" { if text != "" {
_ = b.sink.SendText(fmt.Sprintf("%s: %s", b.labelRX, text)) scopedLogger.Info().Msg("Emitting RX data to sink")
b.emitToTerminal(fmt.Sprintf("%s: %s", b.labelRX, text))
} }
last := data[len(data)-1] last := data[len(data)-1]
@ -286,23 +344,46 @@ func (b *ConsoleBroker) handleRX(data []byte) {
} }
func (b *ConsoleBroker) handleTX(data []byte) { func (b *ConsoleBroker) handleTX(data []byte) {
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker TX handler").Logger()
if b.sink == nil || len(data) == 0 { if b.sink == nil || len(data) == 0 {
return return
} }
if b.rxAtLineEnd && b.pendingTX == nil { if b.rxAtLineEnd && b.pendingTX == nil {
_ = b.sink.SendText("\r\n") scopedLogger.Info().Msg("Emitting TX data to sink immediately")
b.emitTX(data) b.emitTX(data)
b.rxAtLineEnd = true
return return
} }
scopedLogger.Info().Msg("Queuing TX data to emit after RX line completion or quiet period")
b.pendingTX = &consoleEvent{kind: evTX, data: append([]byte(nil), data...)} b.pendingTX = &consoleEvent{kind: evTX, data: append([]byte(nil), data...)}
b.startQuietTimer() b.startQuietTimer()
} }
func (b *ConsoleBroker) emitTX(data []byte) { func (b *ConsoleBroker) emitTX(data []byte) {
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker TX emiter").Logger()
if len(data) == 0 {
return
}
text := normalize(data, b.norm) text := normalize(data, b.norm)
if text != "" { if text == "" {
_ = b.sink.SendText(fmt.Sprintf("%s: %s\r\n", b.labelTX, text)) return
}
// Check if were in the middle of a TX line
if !b.txLineActive {
// Start new TX line with prefix
scopedLogger.Info().Msg("Emitting TX data to sink with prefix")
b.emitToTerminal(fmt.Sprintf("%s: %s", b.labelTX, text))
b.txLineActive = true
} else {
// Continue current line (no prefix)
scopedLogger.Info().Msg("Emitting TX data to sink without prefix")
b.emitToTerminal(text)
}
// If the data ends with a newline, mark TX line as complete
if strings.HasSuffix(text, "\r") || strings.HasSuffix(text, "\n") {
b.txLineActive = false
} }
} }
@ -312,6 +393,57 @@ func (b *ConsoleBroker) flushPendingTX() {
} }
b.emitTX(b.pendingTX.data) b.emitTX(b.pendingTX.data)
b.pendingTX = nil b.pendingTX = nil
b.txLineActive = false
}
func (b *ConsoleBroker) lineSep() string {
switch b.norm.CRLF {
case CRLF_CRLF:
return "\r\n"
case CRLF_CR:
return "\r"
case CRLF_LF:
return "\n"
default:
return "\n"
}
}
func (b *ConsoleBroker) emitToTerminal(s string) {
if b.sink == nil || s == "" {
return
}
if b.terminalPaused {
b.enqueueBuffered(s)
return
}
_ = b.sink.SendText(s)
}
func (b *ConsoleBroker) enqueueBuffered(s string) {
b.bufLines = append(b.bufLines, s)
b.bufBytes += len(s)
// trim if over limits (drop oldest)
for b.bufBytes > b.maxBufBytes || len(b.bufLines) > b.maxBufLines {
if len(b.bufLines) == 0 {
break
}
b.bufBytes -= len(b.bufLines[0])
b.bufLines = b.bufLines[1:]
}
}
func (b *ConsoleBroker) flushBuffer() {
if b.sink == nil || len(b.bufLines) == 0 {
b.bufLines = nil
b.bufBytes = 0
return
}
for _, s := range b.bufLines {
_ = b.sink.SendText(s)
}
b.bufLines = nil
b.bufBytes = 0
} }
/* ---------- SERIAL MUX (single reader/writer, emits to broker) ---------- */ /* ---------- SERIAL MUX (single reader/writer, emits to broker) ---------- */
@ -351,10 +483,12 @@ func (m *SerialMux) Close() { close(m.done) }
func (m *SerialMux) SetEchoEnabled(v bool) { m.echoEnabled.Store(v) } func (m *SerialMux) SetEchoEnabled(v bool) { m.echoEnabled.Store(v) }
func (m *SerialMux) Enqueue(payload []byte, source string, requestEcho bool) { func (m *SerialMux) Enqueue(payload []byte, source string, requestEcho bool) {
serialLogger.Info().Str("src", source).Bool("echo", requestEcho).Msg("Enqueuing TX data to serial port")
m.txQ <- txFrame{payload: append([]byte(nil), payload...), source: source, echo: requestEcho} m.txQ <- txFrame{payload: append([]byte(nil), payload...), source: source, echo: requestEcho}
} }
func (m *SerialMux) reader() { func (m *SerialMux) reader() {
scopedLogger := serialLogger.With().Str("service", "SerialMux reader").Logger()
buf := make([]byte, 4096) buf := make([]byte, 4096)
for { for {
select { select {
@ -370,6 +504,7 @@ func (m *SerialMux) reader() {
continue continue
} }
if n > 0 && m.broker != nil { if n > 0 && m.broker != nil {
scopedLogger.Info().Msg("Sending RX data to console broker")
m.broker.Enqueue(consoleEvent{kind: evRX, data: append([]byte(nil), buf[:n]...)}) m.broker.Enqueue(consoleEvent{kind: evRX, data: append([]byte(nil), buf[:n]...)})
} }
} }
@ -377,17 +512,20 @@ func (m *SerialMux) reader() {
} }
func (m *SerialMux) writer() { func (m *SerialMux) writer() {
scopedLogger := serialLogger.With().Str("service", "SerialMux writer").Logger()
for { for {
select { select {
case <-m.done: case <-m.done:
return return
case f := <-m.txQ: case f := <-m.txQ:
scopedLogger.Info().Msg("Writing TX data to serial port")
if _, err := m.port.Write(f.payload); err != nil { if _, err := m.port.Write(f.payload); err != nil {
serialLogger.Warn().Err(err).Str("src", f.source).Msg("serial write failed") scopedLogger.Warn().Err(err).Str("src", f.source).Msg("serial write failed")
continue continue
} }
// echo (if requested AND globally enabled) // echo (if requested AND globally enabled)
if f.echo && m.echoEnabled.Load() && m.broker != nil { if f.echo && m.echoEnabled.Load() && m.broker != nil {
scopedLogger.Info().Msg("Sending TX echo to console broker")
m.broker.Enqueue(consoleEvent{kind: evTX, data: append([]byte(nil), f.payload...)}) m.broker.Enqueue(consoleEvent{kind: evTX, data: append([]byte(nil), f.payload...)})
} }
} }

View File

@ -13,6 +13,20 @@ function useCommandHistory(max = 300) {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const [items, setItems] = useState<string[]>([]); const [items, setItems] = useState<string[]>([]);
const deleteHistory = useCallback(() => {
console.log("Deleting serial command history");
send("deleteSerialCommandHistory", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to delete serial command history: ${resp.error.data || "Unknown error"}`,
);
} else {
setItems([]);
notifications.success("Serial command history deleted");
}
});
}, [send]);
useEffect(() => { useEffect(() => {
send("getSerialCommandHistory", {}, (resp: JsonRpcResponse) => { send("getSerialCommandHistory", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
@ -90,7 +104,7 @@ function useCommandHistory(max = 300) {
.reverse(); // newest first .reverse(); // newest first
}, [items]); }, [items]);
return { push, up, down, resetTraversal, search }; return { push, up, down, resetTraversal, search, deleteHistory };
} }
function Portal({ children }: { children: React.ReactNode }) { function Portal({ children }: { children: React.ReactNode }) {
@ -102,7 +116,7 @@ function Portal({ children }: { children: React.ReactNode }) {
// ---------- reverse search popup ---------- // ---------- reverse search popup ----------
function ReverseSearch({ function ReverseSearch({
open, results, sel, setSel, onPick, onClose, open, results, sel, setSel, onPick, onClose, onDeleteHistory
}: { }: {
open: boolean; open: boolean;
results: Hit[]; results: Hit[];
@ -110,6 +124,7 @@ function ReverseSearch({
setSel: (i: number) => void; setSel: (i: number) => void;
onPick: (val: string) => void; onPick: (val: string) => void;
onClose: () => void; onClose: () => void;
onDeleteHistory: () => void;
}) { }) {
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
@ -151,7 +166,10 @@ function ReverseSearch({
</div> </div>
<div className="mt-1 flex justify-between text-s text-slate-400"> <div className="mt-1 flex justify-between text-s text-slate-400">
<span>/ select Enter accept Esc close</span> <span>/ select Enter accept Esc close</span>
<button className="underline" onClick={onClose}>Close</button> <div>
<button className="underline mr-2" onClick={onClose}>Close</button>
<button className="underline mr-2" onClick={onDeleteHistory}>Delete history</button>
</div>
</div> </div>
</div> </div>
</Portal> </Portal>
@ -177,7 +195,7 @@ export function CommandInput({
const [revOpen, setRevOpen] = useState(false); const [revOpen, setRevOpen] = useState(false);
const [revQuery, setRevQuery] = useState(""); const [revQuery, setRevQuery] = useState("");
const [sel, setSel] = useState(0); const [sel, setSel] = useState(0);
const { push, up, down, resetTraversal, search } = useCommandHistory(); const { push, up, down, resetTraversal, search, deleteHistory } = useCommandHistory();
const results = useMemo(() => search(revQuery), [revQuery, search]); const results = useMemo(() => search(revQuery), [revQuery, search]);
@ -280,6 +298,7 @@ export function CommandInput({
setSel={setSel} setSel={setSel}
onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }} onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }}
onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}} onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}}
onDeleteHistory={deleteHistory}
/> />
</div> </div>
)} )}

View File

@ -1,6 +1,6 @@
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon, PauseCircleIcon, PlayCircleIcon } from "@heroicons/react/16/solid";
import { useEffect, useMemo, useCallback } from "react"; import { useEffect, useMemo, useCallback, useState } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
@ -9,8 +9,10 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard"; import { ClipboardAddon } from "@xterm/addon-clipboard";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores"; import { AvailableTerminalTypes, useUiStore, useTerminalStore } from "@/hooks/stores";
import { CommandInput } from "@/components/CommandInput"; import { CommandInput } from "@/components/CommandInput";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { Button } from "./Button"; import { Button } from "./Button";
@ -67,20 +69,16 @@ function Terminal({
readonly dataChannel: RTCDataChannel; readonly dataChannel: RTCDataChannel;
readonly type: AvailableTerminalTypes; readonly type: AvailableTerminalTypes;
}) { }) {
const { terminalLineMode, terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore();
const { terminator } = useTerminalStore();
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
const [ terminalPaused, setTerminalPaused ] = useState(false)
const isTerminalTypeEnabled = useMemo(() => { const isTerminalTypeEnabled = useMemo(() => {
console.log("Terminal type:", terminalType, "Checking against:", type); console.log("Terminal type:", terminalType, "Checking against:", type);
return terminalType == type; return terminalType == type;
}, [terminalType, type]); }, [terminalType, type]);
useEffect(() => {
if (!instance) return;
instance.options.disableStdin = !terminalLineMode;
instance.options.cursorStyle = terminalLineMode ? "bar" : "block";
}, [instance, terminalLineMode]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
setDisableVideoFocusTrap(isTerminalTypeEnabled); setDisableVideoFocusTrap(isTerminalTypeEnabled);
@ -92,6 +90,18 @@ function Terminal({
}, [setDisableVideoFocusTrap, isTerminalTypeEnabled]); }, [setDisableVideoFocusTrap, isTerminalTypeEnabled]);
const readyState = dataChannel.readyState; const readyState = dataChannel.readyState;
const { send } = useJsonRpc();
const handleTerminalPauseChange = () => {
send("setTerminalPaused", { terminalPaused: !terminalPaused }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to update terminal pause state: ${resp.error.data || "Unknown error"}`);
return;
}
setTerminalPaused(!terminalPaused);
});
};
useEffect(() => { useEffect(() => {
if (!instance) return; if (!instance) return;
if (readyState !== "open") return; if (readyState !== "open") return;
@ -101,6 +111,11 @@ function Terminal({
dataChannel.addEventListener( dataChannel.addEventListener(
"message", "message",
e => { e => {
if (typeof e.data === "string") {
instance.write(e.data); // text path
return;
}
// binary path (if the server ever sends bytes)
// Handle binary data differently based on browser implementation // Handle binary data differently based on browser implementation
// Firefox sends data as blobs, chrome sends data as arraybuffer // Firefox sends data as blobs, chrome sends data as arraybuffer
if (binaryType === "arraybuffer") { if (binaryType === "arraybuffer") {
@ -118,7 +133,12 @@ function Terminal({
); );
const onDataHandler = instance.onData(data => { const onDataHandler = instance.onData(data => {
dataChannel.send(data); if (data === "\r") {
// Intercept enter key to add terminator
dataChannel.send(terminator ?? "");
} else {
dataChannel.send(data);
}
}); });
// Setup escape key handler // Setup escape key handler
@ -141,7 +161,7 @@ function Terminal({
onDataHandler.dispose(); onDataHandler.dispose();
onKeyHandler.dispose(); onKeyHandler.dispose();
}; };
}, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType]); }, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType, terminator]);
useEffect(() => { useEffect(() => {
if (!instance) return; if (!instance) return;
@ -172,8 +192,8 @@ function Terminal({
const sendLine = useCallback((line: string) => { const sendLine = useCallback((line: string) => {
// Just send; line ending/echo/normalization handled in serial.go // Just send; line ending/echo/normalization handled in serial.go
dataChannel.send(line); dataChannel.send(line + terminator);
}, [dataChannel]); }, [dataChannel, terminator]);
return ( return (
<div <div
@ -202,6 +222,17 @@ function Terminal({
{title} {title}
</h2> </h2>
<div className="absolute right-2"> <div className="absolute right-2">
{terminalType == "serial" && (
<Button
size="XS"
theme="light"
text={terminalPaused ? "Resume" : "Pause"}
LeadingIcon={terminalPaused ? PlayCircleIcon : PauseCircleIcon}
onClick={() => {
handleTerminalPauseChange();
}}
/>
)}
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
@ -213,8 +244,8 @@ function Terminal({
</div> </div>
<div className="h-[calc(100%-36px)] p-3"> <div className="h-[calc(100%-36px)] p-3">
<div key="serial" ref={ref} style={{height: (terminalType === "serial" && terminalLineMode) ? "90%" : "100%", width: "100%" }} /> <div key="serial" ref={ref} style={{height: terminalType === "serial" ? "90%" : "100%", width: "100%" }} />
{terminalType == "serial" && terminalLineMode && ( {terminalType == "serial" && (
<CommandInput <CommandInput
placeholder="Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)" placeholder="Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)"
onSend={sendLine} onSend={sendLine}

View File

@ -1,473 +0,0 @@
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";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { InputFieldWithLabel } from "@components/InputField";
import { useUiStore } from "@/hooks/stores";
import Checkbox from "@components/Checkbox";
import {SettingsItem} from "@components/SettingsItem";
/** ============== Types ============== */
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 CustomButtonSettings {
baudRate: number;
dataBits: number;
stopBits: string;
parity: string;
terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR
lineMode: boolean;
hideSerialSettings: 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();
// 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: [],
});
// editor modal state
const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null);
const [draftLabel, setDraftLabel] = useState("");
const [draftCmd, setDraftCmd] = useState("");
const [draftTerminator, setDraftTerminator] = useState({label: "CR (\\r)", value: "\r"});
// load serial settings like SerialConsole
useEffect(() => {
send("getSerialButtonConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get button config: ${resp.error.data || "Unknown error"}`,
);
return;
}
setButtonConfig(resp.result as CustomButtonSettings);
setTerminalLineMode((resp.result as CustomButtonSettings).lineMode);
});
}, [send, setTerminalLineMode]);
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);
};
const onClickButton = (btn: QuickButton) => {
const command = btn.command + btn.terminator.value;
send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to send custom command: ${resp.error.data || "Unknown error"}`,
);
}
});
};
/** CRUD helpers */
const addNew = () => {
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) => {
const nextButtons = buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) ;
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
const moveUpBtn = (id: string) => {
// Make a copy so we don't mutate state directly
const newButtons = [...buttonConfig.buttons];
// Find the index of the button to move
const index = newButtons.findIndex(b => b.id === id);
if (index > 0) {
// Swap with the previous element
[newButtons[index - 1], newButtons[index]] = [
newButtons[index],
newButtons[index - 1],
];
}
// Re-assign sort values
const nextButtons = newButtons.map((b, i) => ({ ...b, sort: i }));
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
const moveDownBtn = (id: string) => {
// Make a copy so we don't mutate state directly
const newButtons = [...buttonConfig.buttons];
// Find the index of the button to move
const index = newButtons.findIndex(b => b.id === id);
if (index >= 0 && index < newButtons.length - 1) {
// Swap with the next element
[newButtons[index], newButtons[index + 1]] = [
newButtons[index + 1],
newButtons[index],
];
}
// Re-assign sort values
const nextButtons = newButtons.map((b, i) => ({ ...b, sort: i }));
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
const saveDraft = () => {
const label = draftLabel.trim() || "Unnamed";
const command = draftCmd;
if (!command) {
notifications.error("Command cannot be empty.");
return;
}
const terminator = draftTerminator;
// 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(() => buttonConfig.buttons, [buttonConfig.buttons]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Serial Buttons"
description="Quick custom commands over the extension serial port"
/>
<Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Top actions */}
<div className="flex flex-wrap justify-around items-center gap-3">
<Button
size="XS"
theme="primary"
LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff}
text={buttonConfig.hideSerialSettings ? "Show Settings" : "Hide Settings"}
onClick={() => handleSerialButtonConfigChange("hideSerialSettings", !buttonConfig.hideSerialSettings )}
/>
<Button
size="XS"
theme="primary"
LeadingIcon={LuPlus}
text="Add Button"
onClick={addNew}
/>
<Button
size="XS"
theme="primary"
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" />
{/* Serial settings (collapsible) */}
{!buttonConfig.hideSerialSettings && (
<>
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
{ label: "4800", value: "4800" },
{ label: "9600", value: "9600" },
{ label: "19200", value: "19200" },
{ label: "38400", value: "38400" },
{ label: "57600", value: "57600" },
{ label: "115200", value: "115200" },
]}
value={buttonConfig.baudRate}
onChange={(e) => handleSerialButtonConfigChange("baudRate", Number(e.target.value))}
/>
<SelectMenuBasic
label="Data Bits"
options={[
{ label: "8", value: "8" },
{ label: "7", value: "7" },
]}
value={buttonConfig.dataBits}
onChange={(e) => handleSerialButtonConfigChange("dataBits", Number(e.target.value))}
/>
<SelectMenuBasic
label="Stop Bits"
options={[
{ label: "1", value: "1" },
{ label: "1.5", value: "1.5" },
{ label: "2", value: "2" },
]}
value={buttonConfig.stopBits}
onChange={(e) => handleSerialButtonConfigChange("stopBits", e.target.value)}
/>
<SelectMenuBasic
label="Parity"
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },
{ label: "Odd", value: "odd" },
]}
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>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
</>
)}
{/* Buttons grid */}
<div className="grid grid-cols-2 gap-2 pt-2">
{sortedButtons.map((btn) => (
<div key={btn.id} className="flex items-stretch gap-2 min-w-0">
<div className=" flex-1 min-w-0 ">
<Button
size="MD"
fullWidth
className="overflow-hidden text-ellipsis whitespace-nowrap"
theme="primary"
text={btn.label}
onClick={() => onClickButton(btn)}
/>
</div>
<Button
size="MD"
theme="light"
className="shrink-0"
LeadingIcon={LuPencil}
onClick={() => editBtn(btn)}
aria-label={`Edit ${btn.label}`}
/>
</div>
))}
{sortedButtons.length === 0 && (
<div className="col-span-2 text-sm text-black dark:text-slate-300">No buttons yet. Click Add Button.</div>
)}
</div>
{/* Editor drawer/modal (inline lightweight) */}
{editorOpen && (
<div className="mt-4 border rounded-md p-3 bg-slate-50 dark:bg-slate-900/30">
<div className="flex items-center gap-2 mb-2">
<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 h-23">
<div>
<InputFieldWithLabel
size="SM"
type="text"
label="Label"
placeholder="New Command"
value={draftLabel}
onChange={e => {
setDraftLabel(e.target.value);
}}
/>
</div>
<div>
<InputFieldWithLabel
size="SM"
type="text"
label="Command"
placeholder="Command to send"
value={draftCmd}
onChange={e => {
setDraftCmd(e.target.value);
}}
/>
{draftTerminator.value != "" && (
<div className="text-xs text-white opacity-70 mt-1">
When sent, the selected line ending ({draftTerminator.label}) will be appended.
</div>
)}
</div>
</div>
<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
size="SM"
theme="danger"
LeadingIcon={LuTrash2}
text="Delete"
onClick={() => removeBtn(editorOpen.id!)}
aria-label={`Delete ${draftLabel}`}
/>
<Button
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!)}
/>
</>
)}
</div>
</div>
)}
</div>
</Card>
</div>
);
}
/** ============== helpers ============== */
function genId() {
return "b_" + Math.random().toString(36).slice(2, 10);
}
function stableSort(arr: QuickButton[]) {
return [...arr].sort((a, b) => (a.sort - b.sort) || a.label.localeCompare(b.label));
}

View File

@ -1,131 +1,520 @@
import { LuTerminal } from "react-icons/lu"; import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCircleX, LuTerminal } from "react-icons/lu";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { InputFieldWithLabel } from "@components/InputField";
import { useUiStore, useTerminalStore } from "@/hooks/stores";
import Checkbox from "@components/Checkbox";
import {SettingsItem} from "@components/SettingsItem";
interface SerialSettings {
baudRate: string;
dataBits: string; /** ============== Types ============== */
stopBits: string; interface QuickButton {
parity: string; 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 SerialSettings {
baudRate: number;
dataBits: number;
stopBits: string;
parity: string;
terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR
hideSerialSettings: boolean;
enableEcho: boolean; // future use
normalizeMode: string; // future use
normalizeLineEnd: string; // future use
preserveANSI: boolean; // future use
buttons: QuickButton[];
}
/** ============== Component ============== */
export function SerialConsole() { export function SerialConsole() {
const { setTerminalType } = useUiStore();
const { setTerminator } = useTerminalStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600", // extension config (buttons + prefs)
dataBits: "8", const [buttonConfig, setButtonConfig] = useState<SerialSettings>({
baudRate: 9600,
dataBits: 8,
stopBits: "1", stopBits: "1",
parity: "none", parity: "none",
terminator: {label: "LF (\\n)", value: "\n"},
hideSerialSettings: false,
enableEcho: false,
normalizeMode: "names",
normalizeLineEnd: "keep",
preserveANSI: true,
buttons: [],
}); });
type NormalizeMode = "caret" | "names" | "hex"; // note: caret (not carret)
const normalizeHelp: Record<NormalizeMode, string> = {
caret: "Caret notation: e.g. Ctrl+A as ^A, Esc as ^[",
names: "Names: e.g. Ctrl+A as <SOH>, Esc as <ESC>",
hex: "Hex notation: e.g. Ctrl+A as 0x01, Esc as 0x1B",
};
// editor modal state
const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null);
const [draftLabel, setDraftLabel] = useState("");
const [draftCmd, setDraftCmd] = useState("");
const [draftTerminator, setDraftTerminator] = useState({label: "LF (\\n)", value: "\n"});
// load serial settings like SerialConsole
useEffect(() => { useEffect(() => {
send("getSerialSettings", {}, (resp: JsonRpcResponse) => { send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`, `Failed to get button config: ${resp.error.data || "Unknown error"}`,
); );
return; return;
} }
setSettings(resp.result as SerialSettings);
});
}, [send]);
const handleSettingChange = (setting: keyof SerialSettings, value: string) => { setButtonConfig(resp.result as SerialSettings);
const newSettings = { ...settings, [setting]: value }; setTerminator((resp.result as SerialSettings).terminator.value);
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => { });
}, [send, setTerminator]);
const handleSerialSettingsChange = (config: keyof SerialSettings, value: unknown) => {
const newButtonConfig = { ...buttonConfig, [config]: value };
send("setSerialSettings", { settings: newButtonConfig }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to update serial settings: ${resp.error.data || "Unknown error"}`);
return;
}
});
setButtonConfig(newButtonConfig);
};
const onClickButton = (btn: QuickButton) => {
const command = btn.command + btn.terminator.value;
send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`, `Failed to send custom command: ${resp.error.data || "Unknown error"}`,
); );
return;
} }
setSettings(newSettings);
}); });
}; };
const { setTerminalType } = useUiStore();
/** CRUD helpers */
const addNew = () => {
setEditorOpen({ id: undefined });
setDraftLabel("");
setDraftCmd("");
setDraftTerminator({label: "LF (\\n)", value: "\n"});
};
const editBtn = (btn: QuickButton) => {
setEditorOpen({ id: btn.id });
setDraftLabel(btn.label);
setDraftCmd(btn.command);
setDraftTerminator(btn.terminator);
};
const removeBtn = (id: string) => {
const nextButtons = buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) ;
handleSerialSettingsChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
const moveUpBtn = (id: string) => {
// Make a copy so we don't mutate state directly
const newButtons = [...buttonConfig.buttons];
// Find the index of the button to move
const index = newButtons.findIndex(b => b.id === id);
if (index > 0) {
// Swap with the previous element
[newButtons[index - 1], newButtons[index]] = [
newButtons[index],
newButtons[index - 1],
];
}
// Re-assign sort values
const nextButtons = newButtons.map((b, i) => ({ ...b, sort: i }));
handleSerialSettingsChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
const moveDownBtn = (id: string) => {
// Make a copy so we don't mutate state directly
const newButtons = [...buttonConfig.buttons];
// Find the index of the button to move
const index = newButtons.findIndex(b => b.id === id);
if (index >= 0 && index < newButtons.length - 1) {
// Swap with the next element
[newButtons[index], newButtons[index + 1]] = [
newButtons[index + 1],
newButtons[index],
];
}
// Re-assign sort values
const nextButtons = newButtons.map((b, i) => ({ ...b, sort: i }));
handleSerialSettingsChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
const saveDraft = () => {
const label = draftLabel.trim() || "Unnamed";
const command = draftCmd;
if (!command) {
notifications.error("Command cannot be empty.");
return;
}
const terminator = draftTerminator;
console.log("Saving draft:", { label, command, terminator });
// 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 , terminator} : b))
: [...buttonConfig.buttons, { id: genId(), label, command, terminator, sort: buttonConfig.buttons.length }];
handleSerialSettingsChange("buttons", stableSort(nextButtons) );
setEditorOpen(null);
};
/** simple reordering: alphabetical by sort, then label */
const sortedButtons = useMemo(() => buttonConfig.buttons, [buttonConfig.buttons]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Serial Console" title="Serial Console"
description="Configure your serial console settings" description="Configure your serial console settings and create quick command buttons"
/> />
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
{/* Open Console Button */} {/* Top actions */}
<div className="flex items-center"> <div className="flex flex-wrap justify-around items-center gap-3">
<Button <Button
size="SM" size="XS"
theme="primary"
LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff}
text={buttonConfig.hideSerialSettings ? "Show Settings" : "Hide Settings"}
onClick={() => handleSerialSettingsChange("hideSerialSettings", !buttonConfig.hideSerialSettings )}
/>
<Button
size="XS"
theme="primary"
LeadingIcon={LuPlus}
text="Add Button"
onClick={addNew}
/>
<Button
size="XS"
theme="primary" theme="primary"
LeadingIcon={LuTerminal} LeadingIcon={LuTerminal}
text="Open Console" text="Open Console"
onClick={() => { onClick={() => {
setTerminalType("serial"); setTerminalType("serial");
console.log("Opening serial console with settings: ", settings); console.log("Opening serial console with settings: ", buttonConfig);
}} }}
/> />
</div> </div>
<hr className="border-slate-700/30 dark:border-slate-600/30" /> <hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Settings */}
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
{ label: "4800", value: "4800" },
{ label: "9600", value: "9600" },
{ label: "19200", value: "19200" },
{ label: "38400", value: "38400" },
{ label: "57600", value: "57600" },
{ label: "115200", value: "115200" },
]}
value={settings.baudRate}
onChange={e => handleSettingChange("baudRate", e.target.value)}
/>
<SelectMenuBasic {/* Serial settings (collapsible) */}
label="Data Bits" {!buttonConfig.hideSerialSettings && (
options={[ <>
{ label: "8", value: "8" }, <div className="grid grid-cols-2 gap-4">
{ label: "7", value: "7" }, <SelectMenuBasic
]} label="Baud Rate"
value={settings.dataBits} options={[
onChange={e => handleSettingChange("dataBits", e.target.value)} { label: "1200", value: "1200" },
/> { label: "2400", value: "2400" },
{ label: "4800", value: "4800" },
{ label: "9600", value: "9600" },
{ label: "19200", value: "19200" },
{ label: "38400", value: "38400" },
{ label: "57600", value: "57600" },
{ label: "115200", value: "115200" },
]}
value={buttonConfig.baudRate}
onChange={(e) => handleSerialSettingsChange("baudRate", Number(e.target.value))}
/>
<SelectMenuBasic <SelectMenuBasic
label="Stop Bits" label="Data Bits"
options={[ options={[
{ label: "1", value: "1" }, { label: "8", value: "8" },
{ label: "1.5", value: "1.5" }, { label: "7", value: "7" },
{ label: "2", value: "2" }, ]}
]} value={buttonConfig.dataBits}
value={settings.stopBits} onChange={(e) => handleSerialSettingsChange("dataBits", Number(e.target.value))}
onChange={e => handleSettingChange("stopBits", e.target.value)} />
/>
<SelectMenuBasic <SelectMenuBasic
label="Parity" label="Stop Bits"
options={[ options={[
{ label: "None", value: "none" }, { label: "1", value: "1" },
{ label: "Even", value: "even" }, { label: "1.5", value: "1.5" },
{ label: "Odd", value: "odd" }, { label: "2", value: "2" },
]} ]}
value={settings.parity} value={buttonConfig.stopBits}
onChange={e => handleSettingChange("parity", e.target.value)} onChange={(e) => handleSerialSettingsChange("stopBits", e.target.value)}
/> />
<SelectMenuBasic
label="Parity"
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },
{ label: "Odd", value: "odd" },
]}
value={buttonConfig.parity}
onChange={(e) => handleSerialSettingsChange("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) => {
handleSerialSettingsChange("terminator", {label: e.target.selectedOptions[0].text, value: e.target.value})
setTerminator(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="Normalization Mode"
options={[
{ label: "Caret", value: "caret" },
{ label: "Names", value: "names" },
{ label: "Hex", value: "hex" },
]}
value={buttonConfig.normalizeMode}
onChange={(e) => {
handleSerialSettingsChange("normalizeMode", e.target.value)
}}
/>
<div className="text-xs text-white opacity-70 mt-0 ml-2">
{normalizeHelp[(buttonConfig.normalizeMode as NormalizeMode)]}
</div>
</div>
<div>
<SelectMenuBasic
className="mb-1"
label="CRLF Handling"
options={[
{ label: "Keep", value: "keep" },
{ label: "LF", value: "lf" },
{ label: "CR", value: "cr" },
{ label: "CRLF", value: "crlf" },
{ label: "LFCR", value: "lfcr" },
]}
value={buttonConfig.normalizeLineEnd}
onChange={(e) => {
handleSerialSettingsChange("normalizeLineEnd", e.target.value)
}}
/>
</div>
<div>
<SelectMenuBasic
className="mb-1"
label="Preserve ANSI"
options={[
{ label: "Strip escape code", value: "strip" },
{ label: "Keep escape code", value: "keep" },
]}
value={buttonConfig.preserveANSI ? "keep" : "strip"}
onChange={(e) => {
handleSerialSettingsChange("preserveANSI", e.target.value === "keep")
}}
/>
</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 => {
handleSerialSettingsChange("enableEcho", e.target.checked);
}}
/>
</SettingsItem>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
</>
)}
{/* Buttons grid */}
<div className="grid grid-cols-2 gap-2 pt-2">
{sortedButtons.map((btn) => (
<div key={btn.id} className="flex items-stretch gap-2 min-w-0">
<div className=" flex-1 min-w-0 ">
<Button
size="MD"
fullWidth
className="overflow-hidden text-ellipsis whitespace-nowrap"
theme="primary"
text={btn.label}
onClick={() => onClickButton(btn)}
/>
</div>
<Button
size="MD"
theme="light"
className="shrink-0"
LeadingIcon={LuPencil}
onClick={() => editBtn(btn)}
aria-label={`Edit ${btn.label}`}
/>
</div>
))}
{sortedButtons.length === 0 && (
<div className="col-span-2 text-sm text-black dark:text-slate-300">No buttons yet. Click Add Button.</div>
)}
</div> </div>
{/* Editor drawer/modal (inline lightweight) */}
{editorOpen && (
<div className="mt-4 border rounded-md p-3 bg-slate-50 dark:bg-slate-900/30">
<div className="flex items-center gap-2 mb-2">
<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 h-23">
<div>
<InputFieldWithLabel
size="SM"
type="text"
label="Label"
placeholder="New Command"
value={draftLabel}
onChange={e => {
setDraftLabel(e.target.value);
}}
/>
</div>
<div>
<InputFieldWithLabel
size="SM"
type="text"
label="Command"
placeholder="Command to send"
value={draftCmd}
onChange={e => {
setDraftCmd(e.target.value);
}}
/>
{draftTerminator.value != "" && (
<div className="text-xs text-white opacity-70 mt-1">
When sent, the selected line ending ({draftTerminator.label}) will be appended.
</div>
)}
</div>
</div>
<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
size="SM"
theme="danger"
LeadingIcon={LuTrash2}
text="Delete"
onClick={() => removeBtn(editorOpen.id!)}
aria-label={`Delete ${draftLabel}`}
/>
<Button
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!)}
/>
</>
)}
</div>
</div>
)}
</div> </div>
</Card> </Card>
</div> </div>
); );
} }
/** ============== helpers ============== */
function genId() {
return "b_" + Math.random().toString(36).slice(2, 10);
}
function stableSort(arr: QuickButton[]) {
return [...arr].sort((a, b) => (a.sort - b.sort) || a.label.localeCompare(b.label));
}

View File

@ -7,7 +7,6 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole"; import { SerialConsole } from "@components/extensions/SerialConsole";
import { SerialButtons } from "@components/extensions/SerialButtons";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import notifications from "@/notifications"; import notifications from "@/notifications";
@ -37,12 +36,6 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
description: "Access your serial console extension", description: "Access your serial console extension",
icon: LuTerminal, icon: LuTerminal,
}, },
{
id: "serial-buttons",
name: "Serial Buttons",
description: "Send custom serial signals by buttons",
icon: LuTerminal,
},
]; ];
export default function ExtensionPopover() { export default function ExtensionPopover() {
@ -83,8 +76,6 @@ export default function ExtensionPopover() {
return <DCPowerControl />; return <DCPowerControl />;
case "serial-console": case "serial-console":
return <SerialConsole />; return <SerialConsole />;
case "serial-buttons":
return <SerialButtons />;
default: default:
return null; return null;
} }

View File

@ -69,9 +69,6 @@ export interface UIState {
terminalType: AvailableTerminalTypes; terminalType: AvailableTerminalTypes;
setTerminalType: (type: UIState["terminalType"]) => void; setTerminalType: (type: UIState["terminalType"]) => void;
terminalLineMode: boolean;
setTerminalLineMode: (enabled: boolean) => void;
} }
export const useUiStore = create<UIState>(set => ({ export const useUiStore = create<UIState>(set => ({
@ -99,9 +96,6 @@ export const useUiStore = create<UIState>(set => ({
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),
terminalLineMode: true,
setTerminalLineMode: (enabled: boolean) => set({ terminalLineMode: enabled }),
})); }));
export interface RTCState { export interface RTCState {
@ -664,6 +658,18 @@ export const useDeviceStore = create<DeviceState>(set => ({
setSystemVersion: (version: string) => set({ systemVersion: version }), setSystemVersion: (version: string) => set({ systemVersion: version }),
})); }));
export interface TerminalState {
terminator: string | null;
setTerminator: (version: string) => void;
}
export const useTerminalStore = create<TerminalState>(set => ({
terminator: null,
setTerminator: (version: string) => set({ terminator: version }),
}));
export interface DhcpLease { export interface DhcpLease {
ip?: string; ip?: string;
netmask?: string; netmask?: string;