mirror of https://github.com/jetkvm/kvm.git
Update backend, implement pause function in terminal
This commit is contained in:
parent
4ddce3f0ee
commit
3b14267155
301
jsonrpc.go
301
jsonrpc.go
|
|
@ -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
301
serial.go
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 we’re 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 we’re 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...)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue