mirror of https://github.com/jetkvm/kvm.git
Compare commits
21 Commits
2728b03c2c
...
87d7e20f55
| Author | SHA1 | Date |
|---|---|---|
|
|
87d7e20f55 | |
|
|
cee8d6427c | |
|
|
86415bcf2b | |
|
|
668ca5978d | |
|
|
39e67f39f0 | |
|
|
0630a7bcb1 | |
|
|
7c09ac3c08 | |
|
|
e55653068c | |
|
|
2ce5623712 | |
|
|
7b9410c36d | |
|
|
3b14267155 | |
|
|
4ddce3f0ee | |
|
|
897927ea1f | |
|
|
2b6571de1f | |
|
|
c2219d1d15 | |
|
|
c07ae51da3 | |
|
|
cfd5e7cfab | |
|
|
67e9136b03 | |
|
|
d8f670fcba | |
|
|
b2b3ee40a7 | |
|
|
b8d4464904 |
319
jsonrpc.go
319
jsonrpc.go
|
|
@ -10,13 +10,11 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"go.bug.st/serial"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/hidrpc"
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
|
|
@ -795,94 +793,84 @@ func rpcGetATXState() (ATXState, error) {
|
|||
return state, nil
|
||||
}
|
||||
|
||||
type SerialSettings struct {
|
||||
BaudRate string `json:"baudRate"`
|
||||
DataBits string `json:"dataBits"`
|
||||
StopBits string `json:"stopBits"`
|
||||
Parity string `json:"parity"`
|
||||
func rpcSendCustomCommand(command string) error {
|
||||
logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command")
|
||||
err := sendCustomCommand(command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send custom command in jsonrpc: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
return getSerialSettings()
|
||||
}
|
||||
|
||||
var serialPortMode = defaultMode
|
||||
|
||||
func rpcSetSerialSettings(settings SerialSettings) error {
|
||||
baudRate, err := strconv.Atoi(settings.BaudRate)
|
||||
return setSerialSettings(settings)
|
||||
}
|
||||
|
||||
const SerialCommandHistoryPath = "/userdata/serialCommandHistory.json"
|
||||
|
||||
func rpcGetSerialCommandHistory() ([]string, error) {
|
||||
items := []string{}
|
||||
|
||||
file, err := os.Open(SerialCommandHistoryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid baud rate: %v", err)
|
||||
logger.Debug().Msg("SerialCommandHistory file doesn't exist, using default")
|
||||
return items, nil
|
||||
}
|
||||
dataBits, err := strconv.Atoi(settings.DataBits)
|
||||
defer file.Close()
|
||||
|
||||
// load and merge the default config with the user config
|
||||
var loadedItems []string
|
||||
if err := json.NewDecoder(file).Decode(&loadedItems); err != nil {
|
||||
logger.Warn().Err(err).Msg("SerialCommandHistory file JSON parsing failed")
|
||||
return items, nil
|
||||
}
|
||||
|
||||
return loadedItems, nil
|
||||
}
|
||||
|
||||
func rpcSetSerialCommandHistory(commandHistory []string) error {
|
||||
logger.Trace().Str("path", SerialCommandHistoryPath).Msg("Saving serial command history")
|
||||
|
||||
file, err := os.Create(SerialCommandHistoryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid data bits: %v", err)
|
||||
return fmt.Errorf("failed to create SerialCommandHistory file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(commandHistory); err != nil {
|
||||
return fmt.Errorf("failed to encode SerialCommandHistory: %w", 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)
|
||||
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)
|
||||
}
|
||||
|
||||
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 rpcSetTerminalPaused(terminalPaused bool) error {
|
||||
setTerminalPaused(terminalPaused)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1161,91 +1149,96 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro
|
|||
}
|
||||
|
||||
var rpcHandlers = map[string]RPCHandler{
|
||||
"ping": {Func: rpcPing},
|
||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||
"getCloudState": {Func: rpcGetCloudState},
|
||||
"getNetworkState": {Func: rpcGetNetworkState},
|
||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
"getVideoState": {Func: rpcGetVideoState},
|
||||
"getUSBState": {Func: rpcGetUSBState},
|
||||
"unmountImage": {Func: rpcUnmountImage},
|
||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||
"getJigglerState": {Func: rpcGetJigglerState},
|
||||
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||
"getTimezones": {Func: rpcGetTimezones},
|
||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
|
||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||
"getEDID": {Func: rpcGetEDID},
|
||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
|
||||
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
|
||||
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
|
||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||
"tryUpdate": {Func: rpcTryUpdate},
|
||||
"getDevModeState": {Func: rpcGetDevModeState},
|
||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||
"getTLSState": {Func: rpcGetTLSState},
|
||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
||||
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
||||
"getActiveExtension": {Func: rpcGetActiveExtension},
|
||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||
"getATXState": {Func: rpcGetATXState},
|
||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||
"getUsbDevices": {Func: rpcGetUsbDevices},
|
||||
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
||||
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
||||
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||
"ping": {Func: rpcPing},
|
||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||
"getCloudState": {Func: rpcGetCloudState},
|
||||
"getNetworkState": {Func: rpcGetNetworkState},
|
||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
"getVideoState": {Func: rpcGetVideoState},
|
||||
"getUSBState": {Func: rpcGetUSBState},
|
||||
"unmountImage": {Func: rpcUnmountImage},
|
||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||
"getJigglerState": {Func: rpcGetJigglerState},
|
||||
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||
"getTimezones": {Func: rpcGetTimezones},
|
||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
|
||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||
"getEDID": {Func: rpcGetEDID},
|
||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
|
||||
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
|
||||
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
|
||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||
"tryUpdate": {Func: rpcTryUpdate},
|
||||
"getDevModeState": {Func: rpcGetDevModeState},
|
||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||
"getTLSState": {Func: rpcGetTLSState},
|
||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
||||
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
||||
"getActiveExtension": {Func: rpcGetActiveExtension},
|
||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||
"getATXState": {Func: rpcGetATXState},
|
||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}},
|
||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||
"getSerialCommandHistory": {Func: rpcGetSerialCommandHistory},
|
||||
"setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}},
|
||||
"deleteSerialCommandHistory": {Func: rpcDeleteSerialCommandHistory},
|
||||
"setTerminalPaused": {Func: rpcSetTerminalPaused, Params: []string{"terminalPaused"}},
|
||||
"getUsbDevices": {Func: rpcGetUsbDevices},
|
||||
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
||||
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
||||
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||
}
|
||||
|
|
|
|||
356
serial.go
356
serial.go
|
|
@ -2,7 +2,9 @@ package kvm
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -14,6 +16,8 @@ import (
|
|||
const serialPortPath = "/dev/ttyS3"
|
||||
|
||||
var port serial.Port
|
||||
var serialMux *SerialMux
|
||||
var consoleBroker *ConsoleBroker
|
||||
|
||||
func mountATXControl() error {
|
||||
_ = port.SetMode(defaultMode)
|
||||
|
|
@ -251,6 +255,17 @@ func setDCRestoreState(state int) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func sendCustomCommand(command string) error {
|
||||
scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger()
|
||||
scopedLogger.Debug().Msgf("Sending custom command: %q", command)
|
||||
if serialMux == nil {
|
||||
return fmt.Errorf("serial mux not initialized")
|
||||
}
|
||||
payload := []byte(command)
|
||||
serialMux.Enqueue(payload, "button", true) // echo if enabled
|
||||
return nil
|
||||
}
|
||||
|
||||
var defaultMode = &serial.Mode{
|
||||
BaudRate: 115200,
|
||||
DataBits: 8,
|
||||
|
|
@ -258,6 +273,273 @@ var defaultMode = &serial.Mode{
|
|||
StopBits: serial.OneStopBit,
|
||||
}
|
||||
|
||||
var serialPortMode = defaultMode
|
||||
|
||||
var serialConfig = SerialSettings{
|
||||
BaudRate: defaultMode.BaudRate,
|
||||
DataBits: defaultMode.DataBits,
|
||||
Parity: "none",
|
||||
StopBits: "1",
|
||||
Terminator: Terminator{Label: "LF (\\n)", Value: "\n"},
|
||||
HideSerialSettings: false,
|
||||
EnableEcho: false,
|
||||
NormalizeMode: "names",
|
||||
NormalizeLineEnd: "keep",
|
||||
TabRender: "",
|
||||
PreserveANSI: true,
|
||||
ShowNLTag: false,
|
||||
Buttons: []QuickButton{},
|
||||
}
|
||||
|
||||
const serialSettingsPath = "/userdata/serialSettings.json"
|
||||
|
||||
type Terminator struct {
|
||||
Label string `json:"label"` // Terminator label
|
||||
Value string `json:"value"` // Terminator value
|
||||
}
|
||||
|
||||
type QuickButton struct {
|
||||
Id string `json:"id"` // Unique identifier
|
||||
Label string `json:"label"` // Button label
|
||||
Command string `json:"command"` // Command to send, raw command to send (without auto-terminator)
|
||||
Terminator Terminator `json:"terminator"` // Terminator to use: None/CR/LF/CRLF/LFCR
|
||||
Sort int `json:"sort"` // Sort order
|
||||
}
|
||||
|
||||
// Mode describes a serial port configuration.
|
||||
type SerialSettings struct {
|
||||
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)
|
||||
Parity string `json:"parity"` // Parity (see Parity type for more info)
|
||||
StopBits string `json:"stopBits"` // Stop bits (see StopBits type for more info)
|
||||
Terminator Terminator `json:"terminator"` // Terminator to send after each command
|
||||
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
|
||||
NormalizeMode string `json:"normalizeMode"` // Normalization mode: "carret", "names", "hex"
|
||||
NormalizeLineEnd string `json:"normalizeLineEnd"` // Line ending normalization: "keep", "lf", "cr", "crlf", "lfcr"
|
||||
TabRender string `json:"tabRender"` // How to render tabs: "spaces", "arrow", "pipe"
|
||||
PreserveANSI bool `json:"preserveANSI"` // Whether to preserve ANSI escape codes
|
||||
ShowNLTag bool `json:"showNLTag"` // Whether to show a special tag for new lines
|
||||
Buttons []QuickButton `json:"buttons"` // Custom quick buttons
|
||||
}
|
||||
|
||||
func getSerialSettings() (SerialSettings, error) {
|
||||
|
||||
switch defaultMode.StopBits {
|
||||
case serial.OneStopBit:
|
||||
serialConfig.StopBits = "1"
|
||||
case serial.OnePointFiveStopBits:
|
||||
serialConfig.StopBits = "1.5"
|
||||
case serial.TwoStopBits:
|
||||
serialConfig.StopBits = "2"
|
||||
}
|
||||
|
||||
switch defaultMode.Parity {
|
||||
case serial.NoParity:
|
||||
serialConfig.Parity = "none"
|
||||
case serial.OddParity:
|
||||
serialConfig.Parity = "odd"
|
||||
case serial.EvenParity:
|
||||
serialConfig.Parity = "even"
|
||||
case serial.MarkParity:
|
||||
serialConfig.Parity = "mark"
|
||||
case serial.SpaceParity:
|
||||
serialConfig.Parity = "space"
|
||||
}
|
||||
|
||||
file, err := os.Open(serialSettingsPath)
|
||||
if err != nil {
|
||||
logger.Info().Msg("SerialSettings file doesn't exist, using default")
|
||||
return serialConfig, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// load and merge the default config with the user config
|
||||
var loadedConfig SerialSettings
|
||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("SerialSettings file JSON parsing failed")
|
||||
return serialConfig, nil
|
||||
}
|
||||
|
||||
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 "caret":
|
||||
normalizeMode = ModeCaret
|
||||
case "names":
|
||||
normalizeMode = ModeNames
|
||||
case "hex":
|
||||
normalizeMode = ModeHex
|
||||
default:
|
||||
normalizeMode = ModeNames
|
||||
}
|
||||
|
||||
var crlfMode LineEndingMode
|
||||
switch serialConfig.NormalizeLineEnd {
|
||||
case "keep":
|
||||
crlfMode = LineEnding_AsIs
|
||||
case "lf":
|
||||
crlfMode = LineEnding_LF
|
||||
case "cr":
|
||||
crlfMode = LineEnding_CR
|
||||
case "crlf":
|
||||
crlfMode = LineEnding_CRLF
|
||||
case "lfcr":
|
||||
crlfMode = LineEnding_LFCR
|
||||
default:
|
||||
crlfMode = LineEnding_AsIs
|
||||
}
|
||||
|
||||
if consoleBroker != nil {
|
||||
norm := NormalizationOptions{
|
||||
Mode: normalizeMode, LineEnding: crlfMode, TabRender: serialConfig.TabRender, PreserveANSI: serialConfig.PreserveANSI, ShowNLTag: serialConfig.ShowNLTag,
|
||||
}
|
||||
consoleBroker.SetNormOptions(norm)
|
||||
}
|
||||
|
||||
return loadedConfig, nil
|
||||
}
|
||||
|
||||
func setSerialSettings(newSettings SerialSettings) error {
|
||||
logger.Trace().Str("path", serialSettingsPath).Msg("Saving config")
|
||||
|
||||
file, err := os.Create(serialSettingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SerialSettings file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(newSettings); err != nil {
|
||||
return fmt.Errorf("failed to encode SerialSettings: %w", err)
|
||||
}
|
||||
|
||||
var stopBits serial.StopBits
|
||||
switch newSettings.StopBits {
|
||||
case "1":
|
||||
stopBits = serial.OneStopBit
|
||||
case "1.5":
|
||||
stopBits = serial.OnePointFiveStopBits
|
||||
case "2":
|
||||
stopBits = serial.TwoStopBits
|
||||
default:
|
||||
return fmt.Errorf("invalid stop bits: %s", newSettings.StopBits)
|
||||
}
|
||||
|
||||
var parity serial.Parity
|
||||
switch newSettings.Parity {
|
||||
case "none":
|
||||
parity = serial.NoParity
|
||||
case "odd":
|
||||
parity = serial.OddParity
|
||||
case "even":
|
||||
parity = serial.EvenParity
|
||||
case "mark":
|
||||
parity = serial.MarkParity
|
||||
case "space":
|
||||
parity = serial.SpaceParity
|
||||
default:
|
||||
return fmt.Errorf("invalid parity: %s", newSettings.Parity)
|
||||
}
|
||||
serialPortMode = &serial.Mode{
|
||||
BaudRate: newSettings.BaudRate,
|
||||
DataBits: newSettings.DataBits,
|
||||
StopBits: stopBits,
|
||||
Parity: parity,
|
||||
}
|
||||
|
||||
_ = port.SetMode(serialPortMode)
|
||||
|
||||
serialConfig = newSettings // Update global config
|
||||
|
||||
if serialMux != nil {
|
||||
serialMux.SetEchoEnabled(serialConfig.EnableEcho)
|
||||
}
|
||||
|
||||
var normalizeMode NormalizeMode
|
||||
switch serialConfig.NormalizeMode {
|
||||
case "caret":
|
||||
normalizeMode = ModeCaret
|
||||
case "names":
|
||||
normalizeMode = ModeNames
|
||||
case "hex":
|
||||
normalizeMode = ModeHex
|
||||
default:
|
||||
normalizeMode = ModeNames
|
||||
}
|
||||
|
||||
var crlfMode LineEndingMode
|
||||
switch serialConfig.NormalizeLineEnd {
|
||||
case "keep":
|
||||
crlfMode = LineEnding_AsIs
|
||||
case "lf":
|
||||
crlfMode = LineEnding_LF
|
||||
case "cr":
|
||||
crlfMode = LineEnding_CR
|
||||
case "crlf":
|
||||
crlfMode = LineEnding_CRLF
|
||||
case "lfcr":
|
||||
crlfMode = LineEnding_LFCR
|
||||
default:
|
||||
crlfMode = LineEnding_AsIs
|
||||
}
|
||||
|
||||
if consoleBroker != nil {
|
||||
norm := NormalizationOptions{
|
||||
Mode: normalizeMode, LineEnding: crlfMode, TabRender: serialConfig.TabRender, PreserveANSI: serialConfig.PreserveANSI, ShowNLTag: serialConfig.ShowNLTag,
|
||||
}
|
||||
consoleBroker.SetNormOptions(norm)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setTerminalPaused(paused bool) {
|
||||
if consoleBroker != nil {
|
||||
consoleBroker.SetTerminalPaused(paused)
|
||||
}
|
||||
}
|
||||
|
||||
func initSerialPort() {
|
||||
_ = reopenSerialPort()
|
||||
switch config.ActiveExtension {
|
||||
|
|
@ -280,49 +562,65 @@ func reopenSerialPort() error {
|
|||
Str("path", serialPortPath).
|
||||
Interface("mode", defaultMode).
|
||||
Msg("Error opening serial port")
|
||||
return err
|
||||
}
|
||||
|
||||
// new broker (no sink yet—set it in handleSerialChannel.OnOpen)
|
||||
norm := NormalizationOptions{
|
||||
Mode: ModeNames, LineEnding: LineEnding_LF, TabRender: "", PreserveANSI: true,
|
||||
}
|
||||
if consoleBroker != nil {
|
||||
consoleBroker.Close()
|
||||
}
|
||||
consoleBroker = NewConsoleBroker(nil, norm)
|
||||
consoleBroker.Start()
|
||||
|
||||
// new mux
|
||||
if serialMux != nil {
|
||||
serialMux.Close()
|
||||
}
|
||||
serialMux = NewSerialMux(port, consoleBroker)
|
||||
serialMux.SetEchoEnabled(serialConfig.EnableEcho) // honor your setting
|
||||
serialMux.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSerialChannel(d *webrtc.DataChannel) {
|
||||
func handleSerialChannel(dataChannel *webrtc.DataChannel) {
|
||||
scopedLogger := serialLogger.With().
|
||||
Uint16("data_channel_id", *d.ID()).Logger()
|
||||
Uint16("data_channel_id", *dataChannel.ID()).Str("service", "serial terminal channel").Logger()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}()
|
||||
dataChannel.OnOpen(func() {
|
||||
|
||||
// Plug the terminal sink into the broker
|
||||
scopedLogger.Info().Msg("Opening serial channel from console broker")
|
||||
if consoleBroker != nil {
|
||||
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) {
|
||||
if port == nil {
|
||||
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
|
||||
scopedLogger.Trace().Bytes("Data:", msg.Data).Msg("Sending data to serial mux")
|
||||
if serialMux == nil {
|
||||
return
|
||||
}
|
||||
_, err := port.Write(msg.Data)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
|
||||
}
|
||||
|
||||
// requestEcho=true — the mux will honor it only if EnableEcho is on
|
||||
serialMux.Enqueue(msg.Data, "webrtc", true)
|
||||
})
|
||||
|
||||
d.OnError(func(err error) {
|
||||
dataChannel.OnError(func(err error) {
|
||||
scopedLogger.Warn().Err(err).Msg("Serial channel error")
|
||||
})
|
||||
|
||||
d.OnClose(func() {
|
||||
dataChannel.OnClose(func() {
|
||||
scopedLogger.Info().Msg("Serial channel closed")
|
||||
|
||||
if consoleBroker != nil {
|
||||
consoleBroker.SetSink(nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,656 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
/* ---------- SINK (terminal output) ---------- */
|
||||
|
||||
type Sink interface {
|
||||
SendText(s string) error
|
||||
}
|
||||
|
||||
type dataChannelSink struct{ dataChannel *webrtc.DataChannel }
|
||||
|
||||
func (sink dataChannelSink) SendText(str string) error { return sink.dataChannel.SendText(str) }
|
||||
|
||||
/* ---------- NORMALIZATION (applies to RX & TX) ---------- */
|
||||
|
||||
type NormalizeMode int
|
||||
|
||||
const (
|
||||
ModeCaret NormalizeMode = iota // ^C ^M ^?
|
||||
ModeNames // <CR>, <LF>, <ESC>, …
|
||||
ModeHex // \x1B
|
||||
)
|
||||
|
||||
type LineEndingMode int
|
||||
|
||||
const (
|
||||
LineEnding_AsIs LineEndingMode = iota
|
||||
LineEnding_LF
|
||||
LineEnding_CR
|
||||
LineEnding_CRLF
|
||||
LineEnding_LFCR
|
||||
)
|
||||
|
||||
type NormalizationOptions struct {
|
||||
Mode NormalizeMode
|
||||
LineEnding LineEndingMode
|
||||
TabRender string // e.g. " " or "" to keep '\t'
|
||||
PreserveANSI bool
|
||||
ShowNLTag bool // print a visible tag for CR/LF like <CR>, <LF>, <CRLF>
|
||||
}
|
||||
|
||||
func normalize(in []byte, opt NormalizationOptions) string {
|
||||
var out strings.Builder
|
||||
esc := byte(0x1B)
|
||||
for i := 0; i < len(in); {
|
||||
b := in[i]
|
||||
|
||||
// ANSI preservation (CSI/OSC)
|
||||
if opt.PreserveANSI && b == esc && i+1 < len(in) {
|
||||
if in[i+1] == '[' { // CSI
|
||||
j := i + 2
|
||||
for j < len(in) {
|
||||
c := in[j]
|
||||
if c >= 0x40 && c <= 0x7E {
|
||||
j++
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
out.Write(in[i:j])
|
||||
i = j
|
||||
continue
|
||||
} else if in[i+1] == ']' { // OSC ... BEL or ST
|
||||
j := i + 2
|
||||
for j < len(in) {
|
||||
if in[j] == 0x07 {
|
||||
j++
|
||||
break
|
||||
} // BEL
|
||||
if j+1 < len(in) && in[j] == esc && in[j+1] == '\\' {
|
||||
j += 2
|
||||
break
|
||||
} // ST
|
||||
j++
|
||||
}
|
||||
out.Write(in[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// CR/LF normalization (emit real newline(s), optionally tag them visibly)
|
||||
if b == '\r' || b == '\n' {
|
||||
// detect pair (CRLF or LFCR)
|
||||
isPair := i+1 < len(in) &&
|
||||
((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r'))
|
||||
|
||||
// optional visible tag of what we *saw*
|
||||
if opt.ShowNLTag {
|
||||
if isPair {
|
||||
if b == '\r' { // saw CRLF
|
||||
out.WriteString("<CRLF>")
|
||||
} else { // saw LFCR
|
||||
out.WriteString("<LFCR>")
|
||||
}
|
||||
} else {
|
||||
if b == '\r' {
|
||||
out.WriteString("<CR>")
|
||||
} else {
|
||||
out.WriteString("<LF>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// now emit the actual newline(s) per the normalization mode
|
||||
switch opt.LineEnding {
|
||||
case LineEnding_AsIs:
|
||||
if isPair {
|
||||
out.WriteByte(b)
|
||||
out.WriteByte(in[i+1])
|
||||
i += 2
|
||||
} else {
|
||||
out.WriteByte(b)
|
||||
i++
|
||||
}
|
||||
case LineEnding_LF:
|
||||
if isPair {
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
out.WriteByte('\n')
|
||||
case LineEnding_CR:
|
||||
if isPair {
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
out.WriteByte('\r')
|
||||
case LineEnding_CRLF:
|
||||
if isPair {
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
out.WriteString("\r\n")
|
||||
case LineEnding_LFCR:
|
||||
if isPair {
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
out.WriteString("\n\r")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Tabs
|
||||
if b == '\t' {
|
||||
if opt.TabRender != "" {
|
||||
out.WriteString(opt.TabRender)
|
||||
} else {
|
||||
out.WriteByte('\t')
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Controls
|
||||
if b < 0x20 || b == 0x7F {
|
||||
switch opt.Mode {
|
||||
case ModeCaret:
|
||||
if b == 0x7F {
|
||||
out.WriteString("^?")
|
||||
} else {
|
||||
out.WriteByte('^')
|
||||
out.WriteByte(byte('@' + b))
|
||||
}
|
||||
case ModeNames:
|
||||
names := map[byte]string{
|
||||
0: "NUL", 1: "SOH", 2: "STX", 3: "ETX", 4: "EOT", 5: "ENQ", 6: "ACK", 7: "BEL",
|
||||
8: "BS", 9: "TAB", 10: "LF", 11: "VT", 12: "FF", 13: "CR", 14: "SO", 15: "SI",
|
||||
16: "DLE", 17: "DC1", 18: "DC2", 19: "DC3", 20: "DC4", 21: "NAK", 22: "SYN", 23: "ETB",
|
||||
24: "CAN", 25: "EM", 26: "SUB", 27: "ESC", 28: "FS", 29: "GS", 30: "RS", 31: "US", 127: "DEL",
|
||||
}
|
||||
if n, ok := names[b]; ok {
|
||||
out.WriteString("<" + n + ">")
|
||||
} else {
|
||||
out.WriteString(fmt.Sprintf("0x%02X", b))
|
||||
}
|
||||
case ModeHex:
|
||||
out.WriteString(fmt.Sprintf("\\x%02X", b))
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteByte(b)
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
/* ---------- CONSOLE BROKER (ordering + normalization + RX/TX) ---------- */
|
||||
|
||||
type consoleEventKind int
|
||||
|
||||
const (
|
||||
evRX consoleEventKind = iota
|
||||
evTX // local echo after a successful write
|
||||
)
|
||||
|
||||
type consoleEvent struct {
|
||||
kind consoleEventKind
|
||||
data []byte
|
||||
}
|
||||
|
||||
type ConsoleBroker struct {
|
||||
sink Sink
|
||||
in chan consoleEvent
|
||||
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
|
||||
rxAtLineEnd bool
|
||||
txLineActive bool // true if we’re mid-line (prefix already written)
|
||||
pendingTX *consoleEvent
|
||||
quietTimer *time.Timer
|
||||
quietAfter time.Duration
|
||||
|
||||
// normalization
|
||||
norm NormalizationOptions
|
||||
|
||||
// labels
|
||||
labelRX string
|
||||
labelTX string
|
||||
}
|
||||
|
||||
func NewConsoleBroker(s Sink, norm NormalizationOptions) *ConsoleBroker {
|
||||
return &ConsoleBroker{
|
||||
sink: s,
|
||||
in: make(chan consoleEvent, 256),
|
||||
done: make(chan struct{}),
|
||||
pauseCh: make(chan bool, 8),
|
||||
terminalPaused: false,
|
||||
rxAtLineEnd: true,
|
||||
txLineActive: false,
|
||||
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) Close() { close(b.done) }
|
||||
func (b *ConsoleBroker) SetSink(s Sink) { b.sink = s }
|
||||
func (b *ConsoleBroker) SetNormOptions(norm NormalizationOptions) { 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) {
|
||||
b.in <- ev // blocking is fine; adjust if you want drop semantics
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) loop() {
|
||||
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker").Logger()
|
||||
for {
|
||||
select {
|
||||
case <-b.done:
|
||||
return
|
||||
|
||||
case v := <-b.pauseCh:
|
||||
// apply pause state
|
||||
wasPaused := b.terminalPaused
|
||||
b.terminalPaused = v
|
||||
if wasPaused && !v {
|
||||
// we just unpaused: flush buffered output in order
|
||||
scopedLogger.Trace().Msg("Terminal unpaused; flushing buffered output")
|
||||
b.flushBuffer()
|
||||
} else if !wasPaused && v {
|
||||
scopedLogger.Trace().Msg("Terminal paused; buffering output")
|
||||
}
|
||||
|
||||
case ev := <-b.in:
|
||||
switch ev.kind {
|
||||
case evRX:
|
||||
scopedLogger.Trace().Msg("Processing RX data from serial port")
|
||||
b.handleRX(ev.data)
|
||||
case evTX:
|
||||
scopedLogger.Trace().Msg("Processing TX echo request")
|
||||
b.handleTX(ev.data)
|
||||
}
|
||||
|
||||
case <-b.quietCh():
|
||||
if b.pendingTX != nil {
|
||||
b.emitToTerminal(b.lineSep()) // use CRLF policy
|
||||
b.flushPendingTX()
|
||||
b.rxAtLineEnd = true
|
||||
b.txLineActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) quietCh() <-chan time.Time {
|
||||
if b.quietTimer != nil {
|
||||
return b.quietTimer.C
|
||||
}
|
||||
return make(<-chan time.Time)
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) startQuietTimer() {
|
||||
if b.quietTimer == nil {
|
||||
b.quietTimer = time.NewTimer(b.quietAfter)
|
||||
} else {
|
||||
b.quietTimer.Reset(b.quietAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) stopQuietTimer() {
|
||||
if b.quietTimer != nil {
|
||||
if !b.quietTimer.Stop() {
|
||||
select {
|
||||
case <-b.quietTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) handleRX(data []byte) {
|
||||
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker RX handler").Logger()
|
||||
if b.sink == nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// If we’re mid TX line, end it before RX
|
||||
if b.txLineActive {
|
||||
b.emitToTerminal(b.lineSep())
|
||||
b.txLineActive = false
|
||||
}
|
||||
|
||||
text := normalize(data, b.norm)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
scopedLogger.Trace().Msg("Emitting RX data to sink (with per-line prefixes)")
|
||||
|
||||
// Prefix every line, regardless of how the EOLs look
|
||||
lines := splitAfterAnyEOL(text, b.norm.LineEnding)
|
||||
|
||||
// Start from the broker's current RX line state
|
||||
atLineEnd := b.rxAtLineEnd
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if atLineEnd {
|
||||
// New physical line -> prefix with RX:
|
||||
b.emitToTerminal(fmt.Sprintf("%s: %s", b.labelRX, line))
|
||||
} else {
|
||||
// Continuation of previous RX line -> no extra RX: prefix
|
||||
b.emitToTerminal(line)
|
||||
}
|
||||
|
||||
// Update line-end state based on this piece
|
||||
atLineEnd = endsWithEOL(line, b.norm.LineEnding)
|
||||
}
|
||||
|
||||
// Persist state for next RX chunk
|
||||
b.rxAtLineEnd = atLineEnd
|
||||
|
||||
if b.pendingTX != nil && b.rxAtLineEnd {
|
||||
b.flushPendingTX()
|
||||
b.stopQuietTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) handleTX(data []byte) {
|
||||
scopedLogger := serialLogger.With().Str("service", "Serial Console Broker TX handler").Logger()
|
||||
if b.sink == nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
if b.rxAtLineEnd && b.pendingTX == nil {
|
||||
scopedLogger.Trace().Msg("Emitting TX data to sink immediately")
|
||||
b.emitTX(data)
|
||||
return
|
||||
}
|
||||
scopedLogger.Trace().Msg("Queuing TX data to emit after RX line completion or quiet period")
|
||||
b.pendingTX = &consoleEvent{kind: evTX, data: append([]byte(nil), data...)}
|
||||
b.startQuietTimer()
|
||||
}
|
||||
|
||||
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)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we’re in the middle of a TX line
|
||||
if !b.txLineActive {
|
||||
// Start new TX line with prefix
|
||||
scopedLogger.Trace().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.Trace().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
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) flushPendingTX() {
|
||||
if b.pendingTX == nil {
|
||||
return
|
||||
}
|
||||
b.emitTX(b.pendingTX.data)
|
||||
b.pendingTX = nil
|
||||
b.txLineActive = false
|
||||
}
|
||||
|
||||
func (b *ConsoleBroker) lineSep() string {
|
||||
switch b.norm.LineEnding {
|
||||
case LineEnding_CRLF:
|
||||
return "\r\n"
|
||||
case LineEnding_LFCR:
|
||||
return "\n\r"
|
||||
case LineEnding_CR:
|
||||
return "\r"
|
||||
case LineEnding_LF:
|
||||
return "\n"
|
||||
default:
|
||||
return "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// splitAfterAnyEOL splits text into lines keeping the EOL with each piece.
|
||||
// For LineEnding_AsIs it treats \r, \n, \r\n, and \n\r as EOLs.
|
||||
// For other modes it uses the normalized separator.
|
||||
func splitAfterAnyEOL(text string, mode LineEndingMode) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fast path for normalized modes
|
||||
switch mode {
|
||||
case LineEnding_LF:
|
||||
return strings.SplitAfter(text, "\n")
|
||||
case LineEnding_CR:
|
||||
return strings.SplitAfter(text, "\r")
|
||||
case LineEnding_CRLF:
|
||||
return strings.SplitAfter(text, "\r\n")
|
||||
case LineEnding_LFCR:
|
||||
return strings.SplitAfter(text, "\n\r")
|
||||
}
|
||||
|
||||
// LineEnding_AsIs: scan bytes and treat \r, \n, \r\n, \n\r as one boundary
|
||||
b := []byte(text)
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
if b[i] == '\r' || b[i] == '\n' {
|
||||
j := i + 1
|
||||
// coalesce pair if the next is the "other" newline
|
||||
if j < len(b) && ((b[i] == '\r' && b[j] == '\n') || (b[i] == '\n' && b[j] == '\r')) {
|
||||
j++
|
||||
}
|
||||
parts = append(parts, string(b[start:j]))
|
||||
start = j
|
||||
i = j - 1 // advance past the EOL (or pair)
|
||||
}
|
||||
}
|
||||
if start < len(b) {
|
||||
parts = append(parts, string(b[start:]))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func endsWithEOL(s string, mode LineEndingMode) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
switch mode {
|
||||
case LineEnding_CRLF:
|
||||
return strings.HasSuffix(s, "\r\n")
|
||||
case LineEnding_LFCR:
|
||||
return strings.HasSuffix(s, "\n\r")
|
||||
case LineEnding_LF:
|
||||
return strings.HasSuffix(s, "\n")
|
||||
case LineEnding_CR:
|
||||
return strings.HasSuffix(s, "\r")
|
||||
default: // AsIs: any of \r, \n, \r\n, \n\r
|
||||
return strings.HasSuffix(s, "\r\n") ||
|
||||
strings.HasSuffix(s, "\n\r") ||
|
||||
strings.HasSuffix(s, "\n") ||
|
||||
strings.HasSuffix(s, "\r")
|
||||
}
|
||||
}
|
||||
|
||||
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) ---------- */
|
||||
|
||||
type txFrame struct {
|
||||
payload []byte // should include terminator already
|
||||
source string // "webrtc" | "button"
|
||||
echo bool // request TX echo (subject to global toggle)
|
||||
}
|
||||
|
||||
type SerialMux struct {
|
||||
port serial.Port
|
||||
txQ chan txFrame
|
||||
done chan struct{}
|
||||
broker *ConsoleBroker
|
||||
|
||||
echoEnabled atomic.Bool // controlled via SetEchoEnabled
|
||||
}
|
||||
|
||||
func NewSerialMux(p serial.Port, broker *ConsoleBroker) *SerialMux {
|
||||
m := &SerialMux{
|
||||
port: p,
|
||||
txQ: make(chan txFrame, 128),
|
||||
done: make(chan struct{}),
|
||||
broker: broker,
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *SerialMux) Start() {
|
||||
go m.reader()
|
||||
go m.writer()
|
||||
}
|
||||
|
||||
func (m *SerialMux) Close() { close(m.done) }
|
||||
|
||||
func (m *SerialMux) SetEchoEnabled(v bool) { m.echoEnabled.Store(v) }
|
||||
|
||||
func (m *SerialMux) Enqueue(payload []byte, source string, requestEcho bool) {
|
||||
serialLogger.Trace().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}
|
||||
}
|
||||
|
||||
func (m *SerialMux) reader() {
|
||||
scopedLogger := serialLogger.With().Str("service", "SerialMux reader").Logger()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
select {
|
||||
case <-m.done:
|
||||
return
|
||||
default:
|
||||
n, err := m.port.Read(buf)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
serialLogger.Warn().Err(err).Msg("serial read failed")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if n > 0 && m.broker != nil {
|
||||
scopedLogger.Trace().Msg("Sending RX data to console broker")
|
||||
m.broker.Enqueue(consoleEvent{kind: evRX, data: append([]byte(nil), buf[:n]...)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SerialMux) writer() {
|
||||
scopedLogger := serialLogger.With().Str("service", "SerialMux writer").Logger()
|
||||
for {
|
||||
select {
|
||||
case <-m.done:
|
||||
return
|
||||
case f := <-m.txQ:
|
||||
scopedLogger.Trace().Msg("Writing TX data to serial port")
|
||||
if _, err := m.port.Write(f.payload); err != nil {
|
||||
scopedLogger.Warn().Err(err).Str("src", f.source).Msg("serial write failed")
|
||||
continue
|
||||
}
|
||||
// echo (if requested AND globally enabled)
|
||||
if f.echo && m.echoEnabled.Load() && m.broker != nil {
|
||||
scopedLogger.Trace().Msg("Sending TX echo to console broker")
|
||||
m.broker.Enqueue(consoleEvent{kind: evTX, data: append([]byte(nil), f.payload...)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -718,10 +718,26 @@
|
|||
"saving": "Saving…",
|
||||
"search_placeholder": "Search…",
|
||||
"serial_console": "Serial Console",
|
||||
"serial_console_add_button": "Add Button",
|
||||
"serial_console_baud_rate": "Baud Rate",
|
||||
"serial_console_button_editor_command": "Command",
|
||||
"serial_console_button_editor_command_placeholder": "Command to send",
|
||||
"serial_console_button_editor_delete": "Delete",
|
||||
"serial_console_button_editor_explanation": "When sent, the selected line ending {terminator} will be appended.",
|
||||
"serial_console_button_editor_label": "Label",
|
||||
"serial_console_button_editor_label_placeholder": "New Command",
|
||||
"serial_console_button_editor_move_down": "Move Down",
|
||||
"serial_console_button_editor_move_up": "Move Up",
|
||||
"serial_console_configure_description": "Configure your serial console settings",
|
||||
"serial_console_crlf_handling": "CRLF Handling",
|
||||
"serial_console_data_bits": "Data Bits",
|
||||
"serial_console_get_settings_error": "Failed to get serial console settings: {error}",
|
||||
"serial_console_hide_settings": "Hide Settings",
|
||||
"serial_console_line_ending": "Line Ending",
|
||||
"serial_console_line_ending_explanation": "Character(s) sent at the end of each command",
|
||||
"serial_console_local_echo": "Local Echo",
|
||||
"serial_console_local_echo_description": "Show characters you type in the console",
|
||||
"serial_console_normalization_mode": "Normalization Mode",
|
||||
"serial_console_open_console": "Open Console",
|
||||
"serial_console_parity": "Parity",
|
||||
"serial_console_parity_even": "Even Parity",
|
||||
|
|
@ -729,8 +745,18 @@
|
|||
"serial_console_parity_none": "No Parity",
|
||||
"serial_console_parity_odd": "Odd Parity",
|
||||
"serial_console_parity_space": "Space Parity",
|
||||
"serial_console_preserve_ansi": "Preserve ANSI",
|
||||
"serial_console_preserve_ansi_keep": "Keep escape code",
|
||||
"serial_console_preserve_ansi_strip": "Strip escape code",
|
||||
"serial_console_send_custom_command": "Failed to send custom command: {command}: {error}",
|
||||
"serial_console_set_settings_error": "Failed to set serial console settings to {settings}: {error}",
|
||||
"serial_console_show_newline_tag": "Show newline tag",
|
||||
"serial_console_show_newline_tag_hide": "Hide <LF> tag",
|
||||
"serial_console_show_newline_tag_show": "Show <LF> tag",
|
||||
"serial_console_show_settings": "Show Settings",
|
||||
"serial_console_stop_bits": "Stop Bits",
|
||||
"serial_console_tab_replacement": "Tab replacement",
|
||||
"serial_console_tab_replacement_description": "Empty for no replacement",
|
||||
"setting_remote_description": "Setting remote description",
|
||||
"setting_remote_session_description": "Setting remote session description...",
|
||||
"setting_up_connection_to_device": "Setting up connection to device...",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import InputField from "@/components/InputField"; // your existing input component
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
interface Hit { value: string; index: number }
|
||||
|
||||
// ---------- history hook ----------
|
||||
function useCommandHistory(max = 300) {
|
||||
const { send } = useJsonRpc();
|
||||
const [items, setItems] = useState<string[]>([]);
|
||||
|
||||
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(() => {
|
||||
send("getSerialCommandHistory", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get command history: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
} else if ("result" in resp) {
|
||||
setItems(resp.result as string[]);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const [pointer, setPointer] = useState<number>(-1); // -1 = fresh line
|
||||
const [anchorPrefix, setAnchorPrefix] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 1) {
|
||||
send("setSerialCommandHistory", { commandHistory: items }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to update command history: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [items, send]);
|
||||
|
||||
const push = useCallback((cmd: string) => {
|
||||
if (!cmd.trim()) return;
|
||||
setItems((prev) => {
|
||||
const next = prev[prev.length - 1] === cmd ? prev : [...prev, cmd];
|
||||
return next.slice(-max);
|
||||
});
|
||||
setPointer(-1);
|
||||
setAnchorPrefix(null);
|
||||
}, [max]);
|
||||
|
||||
const resetTraversal = useCallback(() => {
|
||||
setPointer(-1);
|
||||
setAnchorPrefix(null);
|
||||
}, []);
|
||||
|
||||
const up = useCallback((draft: string) => {
|
||||
const pref = anchorPrefix ?? draft;
|
||||
if (anchorPrefix == null) setAnchorPrefix(pref);
|
||||
let i = pointer < 0 ? items.length - 1 : pointer - 1;
|
||||
for (; i >= 0; i--) {
|
||||
if (items[i].startsWith(pref)) {
|
||||
setPointer(i);
|
||||
return items[i];
|
||||
}
|
||||
}
|
||||
return draft;
|
||||
}, [items, pointer, anchorPrefix]);
|
||||
|
||||
const down = useCallback((draft: string) => {
|
||||
const pref = anchorPrefix ?? draft;
|
||||
if (anchorPrefix == null) setAnchorPrefix(pref);
|
||||
let i = pointer < 0 ? 0 : pointer + 1;
|
||||
for (; i < items.length; i++) {
|
||||
if (items[i].startsWith(pref)) {
|
||||
setPointer(i);
|
||||
return items[i];
|
||||
}
|
||||
}
|
||||
setPointer(-1);
|
||||
return draft;
|
||||
}, [items, pointer, anchorPrefix]);
|
||||
|
||||
const search = useCallback((query: string): Hit[] => {
|
||||
if (!query) return [];
|
||||
const q = query.toLowerCase();
|
||||
return [...items]
|
||||
.map((value, index) => ({ value, index }))
|
||||
.filter((x) => x.value.toLowerCase().includes(q))
|
||||
.reverse(); // newest first
|
||||
}, [items]);
|
||||
|
||||
return { push, up, down, resetTraversal, search, deleteHistory };
|
||||
}
|
||||
|
||||
function Portal({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
|
||||
// ---------- reverse search popup ----------
|
||||
function ReverseSearch({
|
||||
open, results, sel, setSel, onPick, onClose, onDeleteHistory
|
||||
}: {
|
||||
open: boolean;
|
||||
results: Hit[];
|
||||
sel: number;
|
||||
setSel: (i: number) => void;
|
||||
onPick: (val: string) => void;
|
||||
onClose: () => void;
|
||||
onDeleteHistory: () => void;
|
||||
}) {
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// keep selected item in view when sel changes
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
const el = listRef.current.querySelector<HTMLDivElement>(`[data-idx="${sel}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [sel, results]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
className="absolute bottom-12 left-0 right-0 ml-17 mr-8 mb-5 rounded-md border border-slate-600 bg-slate-900/95 p-2 shadow-lg"
|
||||
role="listbox"
|
||||
aria-activedescendant={`rev-opt-${sel}`}
|
||||
>
|
||||
<div ref={listRef} className="max-h-48 overflow-auto">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-2 py-1 text-sm text-slate-400">No matches</div>
|
||||
) : results.map((r, i) => (
|
||||
<div
|
||||
id={`rev-opt-${i}`}
|
||||
data-idx={i}
|
||||
key={`${r.index}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === sel}
|
||||
className={clsx(
|
||||
"px-2 py-1 font-mono text-sm cursor-pointer",
|
||||
i === sel ? "bg-slate-700 text-white rounded" : "text-slate-200",
|
||||
)}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => onPick(r.value)}
|
||||
>
|
||||
{r.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-s text-slate-400">
|
||||
<span>↑/↓ select • Enter accept • Esc close</span>
|
||||
<div>
|
||||
<button className="underline mr-2" onClick={onClose}>Close</button>
|
||||
<button className="underline mr-2" onClick={onDeleteHistory}>Delete history</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- main component ----------
|
||||
interface CommandInputProps {
|
||||
onSend: (line: string) => void; // called on Enter
|
||||
storageKey?: string; // localStorage key for history
|
||||
placeholder?: string; // input placeholder
|
||||
className?: string; // container className
|
||||
disabled?: boolean; // disable input (optional)
|
||||
}
|
||||
|
||||
export function CommandInput({
|
||||
onSend,
|
||||
placeholder = "Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)",
|
||||
className,
|
||||
disabled,
|
||||
}: CommandInputProps) {
|
||||
const [cmd, setCmd] = useState("");
|
||||
const [revOpen, setRevOpen] = useState(false);
|
||||
const [revQuery, setRevQuery] = useState("");
|
||||
const [sel, setSel] = useState(0);
|
||||
const { push, up, down, resetTraversal, search, deleteHistory } = useCommandHistory();
|
||||
|
||||
const results = useMemo(() => search(revQuery), [revQuery, search]);
|
||||
|
||||
useEffect(() => { setSel(0); }, [results]);
|
||||
|
||||
const cmdInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const isMeta = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey && !isMeta) {
|
||||
e.preventDefault();
|
||||
if (!cmd) return;
|
||||
onSend(cmd);
|
||||
push(cmd);
|
||||
setCmd("");
|
||||
resetTraversal();
|
||||
setRevOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setCmd((prev) => up(prev));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setCmd((prev) => down(prev));
|
||||
return;
|
||||
}
|
||||
if (isMeta && e.key.toLowerCase() === "r") {
|
||||
e.preventDefault();
|
||||
setRevOpen(true);
|
||||
setRevQuery(cmd);
|
||||
setSel(0);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && revOpen) {
|
||||
e.preventDefault();
|
||||
setRevOpen(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("relative", className)}>
|
||||
<div className="flex items-center gap-2" style={{visibility: revOpen ? "hidden" : "unset"} }>
|
||||
<span className="text-xs text-slate-400 select-none">CMD</span>
|
||||
<InputField
|
||||
ref={cmdInputRef}
|
||||
size="MD"
|
||||
disabled={disabled}
|
||||
value={cmd}
|
||||
onChange={(e) => { setCmd(e.target.value); resetTraversal(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reverse search controls */}
|
||||
{revOpen && (
|
||||
<div className="mt-[-40px]">
|
||||
<div className="flex items-center gap-2 bg-[#0f172a]">
|
||||
<span className="text-s text-slate-400 select-none">Search</span>
|
||||
<InputField
|
||||
size="MD"
|
||||
autoFocus
|
||||
value={revQuery}
|
||||
onChange={(e) => setRevQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSel((i) => (i + 1) % Math.max(1, results.length));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSel((i) => (i - 1 + results.length) % Math.max(1, results.length));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const pick = results[sel]?.value ?? results[0]?.value;
|
||||
if (pick) {
|
||||
setCmd(pick);
|
||||
setRevOpen(false);
|
||||
requestAnimationFrame(() => cmdInputRef.current?.focus());
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setRevOpen(false);
|
||||
requestAnimationFrame(() => cmdInputRef.current?.focus());
|
||||
}
|
||||
}}
|
||||
placeholder="Type to filter history…"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<ReverseSearch
|
||||
open={revOpen}
|
||||
results={results}
|
||||
sel={sel}
|
||||
setSel={setSel}
|
||||
onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }}
|
||||
onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}}
|
||||
onDeleteHistory={deleteHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandInput;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
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, useState } from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
|
|
@ -8,10 +8,13 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
|||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
|
||||
import { cx } from "@/cva.config";
|
||||
import { AvailableTerminalTypes, useUiStore } from "@hooks/stores";
|
||||
import { Button } from "@components/Button";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { cx } from "@/cva.config";
|
||||
import { AvailableTerminalTypes, useUiStore, useTerminalStore } from "@/hooks/stores";
|
||||
import { CommandInput } from "@/components/CommandInput";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { Button } from "@components/Button";
|
||||
|
||||
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
||||
|
||||
|
|
@ -67,9 +70,12 @@ function Terminal({
|
|||
readonly type: AvailableTerminalTypes;
|
||||
}) {
|
||||
const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore();
|
||||
const { terminator } = useTerminalStore();
|
||||
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
|
||||
const [ terminalPaused, setTerminalPaused ] = useState(false)
|
||||
|
||||
const isTerminalTypeEnabled = useMemo(() => {
|
||||
console.log("Terminal type:", terminalType, "Checking against:", type);
|
||||
return terminalType == type;
|
||||
}, [terminalType, type]);
|
||||
|
||||
|
|
@ -84,6 +90,18 @@ function Terminal({
|
|||
}, [setDisableVideoFocusTrap, isTerminalTypeEnabled]);
|
||||
|
||||
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(() => {
|
||||
if (!instance) return;
|
||||
if (readyState !== "open") return;
|
||||
|
|
@ -93,6 +111,11 @@ function Terminal({
|
|||
dataChannel.addEventListener(
|
||||
"message",
|
||||
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
|
||||
// Firefox sends data as blobs, chrome sends data as arraybuffer
|
||||
if (binaryType === "arraybuffer") {
|
||||
|
|
@ -110,7 +133,12 @@ function Terminal({
|
|||
);
|
||||
|
||||
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
|
||||
|
|
@ -133,7 +161,7 @@ function Terminal({
|
|||
onDataHandler.dispose();
|
||||
onKeyHandler.dispose();
|
||||
};
|
||||
}, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType]);
|
||||
}, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType, terminator]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance) return;
|
||||
|
|
@ -161,6 +189,11 @@ function Terminal({
|
|||
};
|
||||
}, [instance]);
|
||||
|
||||
const sendLine = useCallback((line: string) => {
|
||||
// Just send; line ending/echo/normalization handled in serial.go
|
||||
dataChannel.send(line + terminator);
|
||||
}, [dataChannel, terminator]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
|
|
@ -188,6 +221,17 @@ function Terminal({
|
|||
{title}
|
||||
</h2>
|
||||
<div className="absolute right-2">
|
||||
{terminalType == "serial" && (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text={terminalPaused ? "Resume" : "Pause"}
|
||||
LeadingIcon={terminalPaused ? PlayCircleIcon : PauseCircleIcon}
|
||||
onClick={() => {
|
||||
handleTerminalPauseChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
|
|
@ -199,7 +243,14 @@ function Terminal({
|
|||
</div>
|
||||
|
||||
<div className="h-[calc(100%-36px)] p-3">
|
||||
<div ref={ref} style={{ height: "100%", width: "100%" }} />
|
||||
<div key="serial" ref={ref} style={{height: terminalType === "serial" ? "90%" : "100%", width: "100%" }} />
|
||||
{terminalType == "serial" && (
|
||||
<CommandInput
|
||||
placeholder="Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)"
|
||||
onSend={sendLine}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,210 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { LuTerminal } from "react-icons/lu";
|
||||
import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCircleX, LuTerminal } from "react-icons/lu";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { useUiStore } from "@hooks/stores";
|
||||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import notifications from "@/notifications";
|
||||
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";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
interface SerialSettings {
|
||||
baudRate: string;
|
||||
dataBits: string;
|
||||
stopBits: string;
|
||||
parity: string;
|
||||
|
||||
/** ============== 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 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
|
||||
tabRender: string; // future use
|
||||
preserveANSI: boolean; // future use
|
||||
showNLTag: boolean; // future use
|
||||
buttons: QuickButton[];
|
||||
}
|
||||
|
||||
/** ============== Component ============== */
|
||||
|
||||
export function SerialConsole() {
|
||||
const { setTerminalType } = useUiStore();
|
||||
const { setTerminator } = useTerminalStore();
|
||||
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
// extension config (buttons + prefs)
|
||||
const [settings, setSettings] = useState<SerialSettings>({
|
||||
baudRate: "9600",
|
||||
dataBits: "8",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: "1",
|
||||
parity: "none",
|
||||
terminator: {label: "LF (\\n)", value: "\n"},
|
||||
hideSerialSettings: false,
|
||||
enableEcho: false,
|
||||
normalizeMode: "names",
|
||||
normalizeLineEnd: "keep",
|
||||
tabRender: "",
|
||||
preserveANSI: true,
|
||||
showNLTag: 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(() => {
|
||||
send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(m.serial_console_get_settings_error({ error: resp.error.data || m.unknown_error() }));
|
||||
return;
|
||||
}
|
||||
setSettings(resp.result as SerialSettings);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleSettingChange = (setting: keyof SerialSettings, value: string) => {
|
||||
const newSettings = { ...settings, [setting]: value };
|
||||
setSettings(resp.result as SerialSettings);
|
||||
setTerminator((resp.result as SerialSettings).terminator.value);
|
||||
});
|
||||
|
||||
}, [send, setTerminator]);
|
||||
|
||||
const handleSerialSettingsChange = (config: keyof SerialSettings, value: unknown) => {
|
||||
const newSettings = { ...settings, [config]: value };
|
||||
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(m.serial_console_set_settings_error({ settings: setting, error: resp.error.data || m.unknown_error() }));
|
||||
notifications.error(m.serial_console_set_settings_error({ settings: newSettings, error: resp.error.data || m.unknown_error() }));
|
||||
return;
|
||||
}
|
||||
});
|
||||
setSettings(newSettings);
|
||||
};
|
||||
|
||||
const onClickButton = (btn: QuickButton) => {
|
||||
|
||||
const command = btn.command + btn.terminator.value;
|
||||
|
||||
send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(m.serial_console_send_custom_command({ command: command, error: resp.error.data || m.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 = settings.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 = [...settings.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 = [...settings.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
|
||||
? settings.buttons.map(b => (b.id === currentID ? { ...b, label, command , terminator} : b))
|
||||
: [...settings.buttons, { id: genId(), label, command, terminator, sort: settings.buttons.length }];
|
||||
|
||||
handleSerialSettingsChange("buttons", stableSort(nextButtons) );
|
||||
setEditorOpen(null);
|
||||
};
|
||||
|
||||
/** simple reordering: alphabetical by sort, then label */
|
||||
const sortedButtons = useMemo(() => settings.buttons, [settings.buttons]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -57,10 +215,24 @@ export function SerialConsole() {
|
|||
|
||||
<Card className="animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Open Console Button */}
|
||||
<div className="flex items-center">
|
||||
{/* Top actions */}
|
||||
<div className="flex flex-wrap justify-around items-center gap-3">
|
||||
<Button
|
||||
size="SM"
|
||||
size="XS"
|
||||
theme="primary"
|
||||
LeadingIcon={settings.hideSerialSettings ? LuEye : LuEyeOff}
|
||||
text={settings.hideSerialSettings ? m.serial_console_show_settings() : m.serial_console_hide_settings()}
|
||||
onClick={() => handleSerialSettingsChange("hideSerialSettings", !settings.hideSerialSettings )}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="primary"
|
||||
LeadingIcon={LuPlus}
|
||||
text={m.serial_console_add_button()}
|
||||
onClick={addNew}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="primary"
|
||||
LeadingIcon={LuTerminal}
|
||||
text={m.serial_console_open_console()}
|
||||
|
|
@ -71,60 +243,309 @@ export function SerialConsole() {
|
|||
/>
|
||||
</div>
|
||||
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||
{/* Settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_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
|
||||
label={m.serial_console_data_bits()}
|
||||
options={[
|
||||
{ label: "8", value: "8" },
|
||||
{ label: "7", value: "7" },
|
||||
]}
|
||||
value={settings.dataBits}
|
||||
onChange={e => handleSettingChange("dataBits", e.target.value)}
|
||||
/>
|
||||
{/* Serial settings (collapsible) */}
|
||||
{!settings.hideSerialSettings && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 mb-1">
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_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) => handleSerialSettingsChange("baudRate", Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_stop_bits()}
|
||||
options={[
|
||||
{ label: "1", value: "1" },
|
||||
{ label: "1.5", value: "1.5" },
|
||||
{ label: "2", value: "2" },
|
||||
]}
|
||||
value={settings.stopBits}
|
||||
onChange={e => handleSettingChange("stopBits", e.target.value)}
|
||||
/>
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_data_bits()}
|
||||
options={[
|
||||
{ label: "8", value: "8" },
|
||||
{ label: "7", value: "7" },
|
||||
]}
|
||||
value={settings.dataBits}
|
||||
onChange={(e) => handleSerialSettingsChange("dataBits", Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_parity()}
|
||||
options={[
|
||||
{ label: m.serial_console_parity_none(), value: "none" },
|
||||
{ label: m.serial_console_parity_even(), value: "even" },
|
||||
{ label: m.serial_console_parity_odd(), value: "odd" },
|
||||
{ label: m.serial_console_parity_mark(), value: "mark" },
|
||||
{ label: m.serial_console_parity_space(), value: "space" },
|
||||
]}
|
||||
value={settings.parity}
|
||||
onChange={e => handleSettingChange("parity", e.target.value)}
|
||||
/>
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_stop_bits()}
|
||||
options={[
|
||||
{ label: "1", value: "1" },
|
||||
{ label: "1.5", value: "1.5" },
|
||||
{ label: "2", value: "2" },
|
||||
]}
|
||||
value={settings.stopBits}
|
||||
onChange={(e) => handleSerialSettingsChange("stopBits", e.target.value)}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_parity()}
|
||||
options={[
|
||||
{ label: m.serial_console_parity_none(), value: "none" },
|
||||
{ label: m.serial_console_parity_even(), value: "even" },
|
||||
{ label: m.serial_console_parity_odd(), value: "odd" },
|
||||
{ label: m.serial_console_parity_mark(), value: "mark" },
|
||||
{ label: m.serial_console_parity_space(), value: "space" },
|
||||
]}
|
||||
value={settings.parity}
|
||||
onChange={(e) => handleSerialSettingsChange("parity", e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label={m.serial_console_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={settings.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">
|
||||
{m.serial_console_line_ending_explanation({terminator: settings.terminator.label})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label={m.serial_console_normalization_mode()}
|
||||
options={[
|
||||
{ label: "Caret", value: "caret" },
|
||||
{ label: "Names", value: "names" },
|
||||
{ label: "Hex", value: "hex" },
|
||||
]}
|
||||
value={settings.normalizeMode}
|
||||
onChange={(e) => {
|
||||
handleSerialSettingsChange("normalizeMode", e.target.value)
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-white opacity-70 mt-0 ml-2">
|
||||
{normalizeHelp[(settings.normalizeMode as NormalizeMode)]}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label={m.serial_console_crlf_handling()}
|
||||
options={[
|
||||
{ label: "Keep", value: "keep" },
|
||||
{ label: "LF", value: "lf" },
|
||||
{ label: "CR", value: "cr" },
|
||||
{ label: "CRLF", value: "crlf" },
|
||||
{ label: "LFCR", value: "lfcr" },
|
||||
]}
|
||||
value={settings.normalizeLineEnd}
|
||||
onChange={(e) => {
|
||||
handleSerialSettingsChange("normalizeLineEnd", e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label={m.serial_console_preserve_ansi()}
|
||||
options={[
|
||||
{ label: m.serial_console_preserve_ansi_strip(), value: "strip" },
|
||||
{ label: m.serial_console_preserve_ansi_keep(), value: "keep" },
|
||||
]}
|
||||
value={settings.preserveANSI ? "keep" : "strip"}
|
||||
onChange={(e) => {
|
||||
handleSerialSettingsChange("preserveANSI", e.target.value === "keep")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SelectMenuBasic
|
||||
className="mb-1"
|
||||
label="Show newline tag"
|
||||
options={[
|
||||
{ label: m.serial_console_show_newline_tag_hide(), value: "hide" },
|
||||
{ label: m.serial_console_show_newline_tag_show(), value: "show" },
|
||||
]}
|
||||
value={settings.showNLTag ? "show" : "hide"}
|
||||
onChange={(e) => {
|
||||
handleSerialSettingsChange("showNLTag", e.target.value === "show")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputFieldWithLabel
|
||||
size="MD"
|
||||
type="text"
|
||||
label={m.serial_console_tab_replacement()}
|
||||
placeholder="ex. spaces, →, |"
|
||||
value={settings.tabRender}
|
||||
onChange={e => {
|
||||
handleSerialSettingsChange("tabRender", e.target.value)
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-white opacity-70 mt-1">
|
||||
{m.serial_console_tab_replacement_description()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 m-2">
|
||||
<SettingsItem
|
||||
title={m.serial_console_local_echo()}
|
||||
description={m.serial_console_local_echo_description()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={settings.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>
|
||||
|
||||
{/* 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={m.serial_console_button_editor_label()}
|
||||
placeholder={m.serial_console_button_editor_label_placeholder()}
|
||||
value={draftLabel}
|
||||
onChange={e => {
|
||||
setDraftLabel(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={m.serial_console_button_editor_command()}
|
||||
placeholder={m.serial_console_button_editor_command_placeholder()}
|
||||
value={draftCmd}
|
||||
onChange={e => {
|
||||
setDraftCmd(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{draftTerminator.value != "" && (
|
||||
<div className="text-xs text-white opacity-70 mt-1">
|
||||
{m.serial_console_button_editor_explanation({terminator: draftTerminator.label})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-around items-end">
|
||||
<SelectMenuBasic
|
||||
label={m.serial_console_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={m.serial_console_button_editor_delete()}
|
||||
onClick={() => removeBtn(editorOpen.id!)}
|
||||
aria-label={`Delete ${draftLabel}`}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuArrowBigUp}
|
||||
text={m.serial_console_button_editor_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={m.serial_console_button_editor_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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -700,6 +700,18 @@ export const useDeviceStore = create<DeviceState>(set => ({
|
|||
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 {
|
||||
ip?: string;
|
||||
netmask?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue