mirror of https://github.com/jetkvm/kvm.git
Compare commits
4 Commits
05a3613fc6
...
8448a1ff8f
| Author | SHA1 | Date |
|---|---|---|
|
|
8448a1ff8f | |
|
|
897927ea1f | |
|
|
2b6571de1f | |
|
|
bdd6f4247b |
|
|
@ -118,7 +118,6 @@ func uiInit(rotation uint16) {
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
cRotation := C.u_int16_t(rotation)
|
cRotation := C.u_int16_t(rotation)
|
||||||
defer C.free(unsafe.Pointer(&cRotation))
|
|
||||||
|
|
||||||
C.jetkvm_ui_init(cRotation)
|
C.jetkvm_ui_init(cRotation)
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +349,6 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
|
||||||
nativeLogger.Info().Uint16("rotation", rotation).Msg("setting rotation")
|
nativeLogger.Info().Uint16("rotation", rotation).Msg("setting rotation")
|
||||||
|
|
||||||
cRotation := C.u_int16_t(rotation)
|
cRotation := C.u_int16_t(rotation)
|
||||||
defer C.free(unsafe.Pointer(&cRotation))
|
|
||||||
|
|
||||||
C.jetkvm_ui_set_rotation(cRotation)
|
C.jetkvm_ui_set_rotation(cRotation)
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|
|
||||||
|
|
@ -820,9 +820,9 @@ func rpcGetATXState() (ATXState, error) {
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSendCustomCommand(command string, terminator string) error {
|
func rpcSendCustomCommand(command string) error {
|
||||||
logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command")
|
logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command")
|
||||||
err := sendCustomCommand(command, terminator)
|
err := sendCustomCommand(command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send custom command in jsonrpc: %w", err)
|
return fmt.Errorf("failed to send custom command in jsonrpc: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -1316,10 +1316,10 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"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", "terminator"}},
|
"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},
|
"getSerialButtonConfig": {Func: rpcGetSerialButtonConfig},
|
||||||
"setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}},
|
"setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}},
|
||||||
"getSerialCommandHistory": {Func: rpcGetSerialCommandHistory},
|
"getSerialCommandHistory": {Func: rpcGetSerialCommandHistory},
|
||||||
"setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}},
|
"setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}},
|
||||||
|
|
|
||||||
161
serial.go
161
serial.go
|
|
@ -18,6 +18,8 @@ import (
|
||||||
const serialPortPath = "/dev/ttyS3"
|
const serialPortPath = "/dev/ttyS3"
|
||||||
|
|
||||||
var port serial.Port
|
var port serial.Port
|
||||||
|
var serialMux *SerialMux
|
||||||
|
var consoleBr *ConsoleBroker
|
||||||
|
|
||||||
func mountATXControl() error {
|
func mountATXControl() error {
|
||||||
_ = port.SetMode(defaultMode)
|
_ = port.SetMode(defaultMode)
|
||||||
|
|
@ -257,12 +259,10 @@ func setDCRestoreState(state int) error {
|
||||||
|
|
||||||
func mountSerialButtons() error {
|
func mountSerialButtons() error {
|
||||||
_ = port.SetMode(defaultMode)
|
_ = port.SetMode(defaultMode)
|
||||||
startSerialButtonsRxLoop(currentSession)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func unmountSerialButtons() error {
|
func unmountSerialButtons() error {
|
||||||
stopSerialButtonsRxLoop()
|
|
||||||
_ = reopenSerialPort()
|
_ = reopenSerialPort()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -322,15 +322,10 @@ func stopSerialButtonsRxLoop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendCustomCommand(command string, terminator 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(terminator))
|
_, err := port.Write([]byte(command))
|
||||||
if err != nil {
|
|
||||||
scopedLogger.Warn().Err(err).Msg("Failed to send terminator")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = port.Write([]byte(command))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Str("Command", command).Msg("Failed to send serial command")
|
scopedLogger.Warn().Err(err).Str("Command", command).Msg("Failed to send serial command")
|
||||||
return err
|
return err
|
||||||
|
|
@ -345,6 +340,18 @@ var defaultMode = &serial.Mode{
|
||||||
StopBits: serial.OneStopBit,
|
StopBits: serial.OneStopBit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var SerialConfig = CustomButtonSettings{
|
||||||
|
BaudRate: defaultMode.BaudRate,
|
||||||
|
DataBits: defaultMode.DataBits,
|
||||||
|
Parity: "none",
|
||||||
|
StopBits: "1",
|
||||||
|
Terminator: Terminator{Label: "CR (\\r)", Value: "\r"},
|
||||||
|
LineMode: true,
|
||||||
|
HideSerialSettings: false,
|
||||||
|
EnableEcho: false,
|
||||||
|
Buttons: []QuickButton{},
|
||||||
|
}
|
||||||
|
|
||||||
const serialSettingsPath = "/userdata/serialSettings.json"
|
const serialSettingsPath = "/userdata/serialSettings.json"
|
||||||
|
|
||||||
type Terminator struct {
|
type Terminator struct {
|
||||||
|
|
@ -362,8 +369,8 @@ type QuickButton struct {
|
||||||
|
|
||||||
// Mode describes a serial port configuration.
|
// Mode describes a serial port configuration.
|
||||||
type CustomButtonSettings struct {
|
type CustomButtonSettings struct {
|
||||||
BaudRate string `json:"baudRate"` // The serial port bitrate (aka Baudrate)
|
BaudRate int `json:"baudRate"` // The serial port bitrate (aka Baudrate)
|
||||||
DataBits string `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
|
||||||
|
|
@ -374,44 +381,33 @@ type CustomButtonSettings struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSerialSettings() (CustomButtonSettings, error) {
|
func getSerialSettings() (CustomButtonSettings, error) {
|
||||||
config := CustomButtonSettings{
|
|
||||||
BaudRate: strconv.Itoa(defaultMode.BaudRate),
|
|
||||||
DataBits: strconv.Itoa(defaultMode.DataBits),
|
|
||||||
Parity: "none",
|
|
||||||
StopBits: "1",
|
|
||||||
Terminator: Terminator{Label: "CR (\\r)", Value: "\r"},
|
|
||||||
LineMode: true,
|
|
||||||
HideSerialSettings: false,
|
|
||||||
EnableEcho: false,
|
|
||||||
Buttons: []QuickButton{},
|
|
||||||
}
|
|
||||||
|
|
||||||
switch defaultMode.StopBits {
|
switch defaultMode.StopBits {
|
||||||
case serial.OneStopBit:
|
case serial.OneStopBit:
|
||||||
config.StopBits = "1"
|
SerialConfig.StopBits = "1"
|
||||||
case serial.OnePointFiveStopBits:
|
case serial.OnePointFiveStopBits:
|
||||||
config.StopBits = "1.5"
|
SerialConfig.StopBits = "1.5"
|
||||||
case serial.TwoStopBits:
|
case serial.TwoStopBits:
|
||||||
config.StopBits = "2"
|
SerialConfig.StopBits = "2"
|
||||||
}
|
}
|
||||||
|
|
||||||
switch defaultMode.Parity {
|
switch defaultMode.Parity {
|
||||||
case serial.NoParity:
|
case serial.NoParity:
|
||||||
config.Parity = "none"
|
SerialConfig.Parity = "none"
|
||||||
case serial.OddParity:
|
case serial.OddParity:
|
||||||
config.Parity = "odd"
|
SerialConfig.Parity = "odd"
|
||||||
case serial.EvenParity:
|
case serial.EvenParity:
|
||||||
config.Parity = "even"
|
SerialConfig.Parity = "even"
|
||||||
case serial.MarkParity:
|
case serial.MarkParity:
|
||||||
config.Parity = "mark"
|
SerialConfig.Parity = "mark"
|
||||||
case serial.SpaceParity:
|
case serial.SpaceParity:
|
||||||
config.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 config, err
|
return SerialConfig, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
|
@ -419,9 +415,11 @@ func getSerialSettings() (CustomButtonSettings, error) {
|
||||||
var loadedConfig CustomButtonSettings
|
var loadedConfig CustomButtonSettings
|
||||||
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 config, nil
|
return SerialConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SerialConfig = loadedConfig // Update global config
|
||||||
|
|
||||||
return loadedConfig, nil
|
return loadedConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,15 +438,6 @@ func setSerialSettings(newSettings CustomButtonSettings) error {
|
||||||
return fmt.Errorf("failed to encode SerialButtons config: %w", err)
|
return fmt.Errorf("failed to encode SerialButtons config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
baudRate, err := strconv.Atoi(newSettings.BaudRate)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid baud rate: %v", err)
|
|
||||||
}
|
|
||||||
dataBits, err := strconv.Atoi(newSettings.DataBits)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid data bits: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stopBits serial.StopBits
|
var stopBits serial.StopBits
|
||||||
switch newSettings.StopBits {
|
switch newSettings.StopBits {
|
||||||
case "1":
|
case "1":
|
||||||
|
|
@ -477,14 +466,20 @@ func setSerialSettings(newSettings CustomButtonSettings) error {
|
||||||
return fmt.Errorf("invalid parity: %s", newSettings.Parity)
|
return fmt.Errorf("invalid parity: %s", newSettings.Parity)
|
||||||
}
|
}
|
||||||
serialPortMode = &serial.Mode{
|
serialPortMode = &serial.Mode{
|
||||||
BaudRate: baudRate,
|
BaudRate: newSettings.BaudRate,
|
||||||
DataBits: dataBits,
|
DataBits: newSettings.DataBits,
|
||||||
StopBits: stopBits,
|
StopBits: stopBits,
|
||||||
Parity: parity,
|
Parity: parity,
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = port.SetMode(serialPortMode)
|
_ = port.SetMode(serialPortMode)
|
||||||
|
|
||||||
|
SerialConfig = newSettings // Update global config
|
||||||
|
|
||||||
|
if serialMux != nil {
|
||||||
|
serialMux.SetEchoEnabled(SerialConfig.EnableEcho)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -510,7 +505,28 @@ func reopenSerialPort() error {
|
||||||
Str("path", serialPortPath).
|
Str("path", serialPortPath).
|
||||||
Interface("mode", defaultMode).
|
Interface("mode", defaultMode).
|
||||||
Msg("Error opening serial port")
|
Msg("Error opening serial port")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// new broker (no sink yet—set it in handleSerialChannel.OnOpen)
|
||||||
|
norm := NormOptions{
|
||||||
|
Mode: ModeCaret, CRLF: CRLF_CRLF, TabRender: "", PreserveANSI: true,
|
||||||
|
}
|
||||||
|
if consoleBr != nil {
|
||||||
|
consoleBr.Close()
|
||||||
|
}
|
||||||
|
consoleBr = NewConsoleBroker(nil, norm)
|
||||||
|
consoleBr.Start()
|
||||||
|
|
||||||
|
// new mux
|
||||||
|
if serialMux != nil {
|
||||||
|
serialMux.Close()
|
||||||
|
}
|
||||||
|
serialMux = NewSerialMux(port, consoleBr)
|
||||||
|
serialMux.SetEchoEnabled(SerialConfig.EnableEcho) // honor your setting
|
||||||
|
serialMux.Start()
|
||||||
|
serialMux.SetEchoEnabled(SerialConfig.EnableEcho)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,33 +535,44 @@ func handleSerialChannel(d *webrtc.DataChannel) {
|
||||||
Uint16("data_channel_id", *d.ID()).Logger()
|
Uint16("data_channel_id", *d.ID()).Logger()
|
||||||
|
|
||||||
d.OnOpen(func() {
|
d.OnOpen(func() {
|
||||||
go func() {
|
// go func() {
|
||||||
buf := make([]byte, 1024)
|
// buf := make([]byte, 1024)
|
||||||
for {
|
// for {
|
||||||
n, err := port.Read(buf)
|
// n, err := port.Read(buf)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
if err != io.EOF {
|
// if err != io.EOF {
|
||||||
scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
|
// scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
|
||||||
}
|
// }
|
||||||
break
|
// break
|
||||||
}
|
// }
|
||||||
err = d.Send(buf[:n])
|
// err = d.Send(buf[:n])
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
|
// scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
|
||||||
break
|
// break
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}()
|
// }()
|
||||||
|
// Plug the terminal sink into the broker
|
||||||
|
if consoleBr != nil {
|
||||||
|
consoleBr.SetSink(dataChannelSink{d: d})
|
||||||
|
_ = d.SendText("RX: [serial attached]\r\n")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
if port == nil {
|
// if port == nil {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// _, 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 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := port.Write(msg.Data)
|
payload := append(msg.Data, []byte(SerialConfig.Terminator.Value)...)
|
||||||
if err != nil {
|
// requestEcho=true — the mux will honor it only if EnableEcho is on
|
||||||
scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
|
serialMux.Enqueue(payload, "webrtc", true)
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
d.OnError(func(err error) {
|
d.OnError(func(err error) {
|
||||||
|
|
@ -554,5 +581,9 @@ func handleSerialChannel(d *webrtc.DataChannel) {
|
||||||
|
|
||||||
d.OnClose(func() {
|
d.OnClose(func() {
|
||||||
scopedLogger.Info().Msg("Serial channel closed")
|
scopedLogger.Info().Msg("Serial channel closed")
|
||||||
|
|
||||||
|
if consoleBr != nil {
|
||||||
|
consoleBr.SetSink(nil)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
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{ d *webrtc.DataChannel }
|
||||||
|
|
||||||
|
func (s dataChannelSink) SendText(str string) error { return s.d.SendText(str) }
|
||||||
|
|
||||||
|
/* ---------- NORMALIZATION (applies to RX & TX) ---------- */
|
||||||
|
|
||||||
|
type NormalizeMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeCaret NormalizeMode = iota // ^C ^M ^?
|
||||||
|
ModeNames // <CR>, <LF>, <ESC>, …
|
||||||
|
ModeHex // \x1B
|
||||||
|
)
|
||||||
|
|
||||||
|
type CRLFMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CRLFAsIs CRLFMode = iota
|
||||||
|
CRLF_CRLF
|
||||||
|
CRLF_LF
|
||||||
|
CRLF_CR
|
||||||
|
)
|
||||||
|
|
||||||
|
type NormOptions struct {
|
||||||
|
Mode NormalizeMode
|
||||||
|
CRLF CRLFMode
|
||||||
|
TabRender string // e.g. " " or "" to keep '\t'
|
||||||
|
PreserveANSI bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(in []byte, opt NormOptions) 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
|
||||||
|
if b == '\r' || b == '\n' {
|
||||||
|
switch opt.CRLF {
|
||||||
|
case CRLFAsIs:
|
||||||
|
out.WriteByte(b)
|
||||||
|
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:
|
||||||
|
if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) {
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.WriteByte('\n')
|
||||||
|
case CRLF_CR:
|
||||||
|
if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) {
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.WriteByte('\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{}
|
||||||
|
|
||||||
|
// line-aware echo
|
||||||
|
rxAtLineEnd bool
|
||||||
|
pendingTX *consoleEvent
|
||||||
|
quietTimer *time.Timer
|
||||||
|
quietAfter time.Duration
|
||||||
|
|
||||||
|
// normalization
|
||||||
|
norm NormOptions
|
||||||
|
|
||||||
|
// labels
|
||||||
|
labelRX string
|
||||||
|
labelTX string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConsoleBroker(s Sink, norm NormOptions) *ConsoleBroker {
|
||||||
|
return &ConsoleBroker{
|
||||||
|
sink: s,
|
||||||
|
in: make(chan consoleEvent, 256),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
rxAtLineEnd: true,
|
||||||
|
quietAfter: 120 * time.Millisecond,
|
||||||
|
norm: norm,
|
||||||
|
labelRX: "RX",
|
||||||
|
labelTX: "TX",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) Enqueue(ev consoleEvent) {
|
||||||
|
b.in <- ev // blocking is fine; adjust if you want drop semantics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ConsoleBroker) loop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-b.done:
|
||||||
|
return
|
||||||
|
case ev := <-b.in:
|
||||||
|
switch ev.kind {
|
||||||
|
case evRX:
|
||||||
|
b.handleRX(ev.data)
|
||||||
|
case evTX:
|
||||||
|
b.handleTX(ev.data)
|
||||||
|
}
|
||||||
|
case <-b.quietCh():
|
||||||
|
if b.pendingTX != nil {
|
||||||
|
_ = b.sink.SendText("\r\n")
|
||||||
|
b.flushPendingTX()
|
||||||
|
b.rxAtLineEnd = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if b.sink == nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := normalize(data, b.norm)
|
||||||
|
if text != "" {
|
||||||
|
_ = b.sink.SendText(fmt.Sprintf("%s: %s", b.labelRX, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
last := data[len(data)-1]
|
||||||
|
b.rxAtLineEnd = (last == '\r' || last == '\n')
|
||||||
|
|
||||||
|
if b.pendingTX != nil && b.rxAtLineEnd {
|
||||||
|
b.flushPendingTX()
|
||||||
|
b.stopQuietTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ConsoleBroker) handleTX(data []byte) {
|
||||||
|
if b.sink == nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.rxAtLineEnd && b.pendingTX == nil {
|
||||||
|
_ = b.sink.SendText("\r\n")
|
||||||
|
b.emitTX(data)
|
||||||
|
b.rxAtLineEnd = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.pendingTX = &consoleEvent{kind: evTX, data: append([]byte(nil), data...)}
|
||||||
|
b.startQuietTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ConsoleBroker) emitTX(data []byte) {
|
||||||
|
text := normalize(data, b.norm)
|
||||||
|
if text != "" {
|
||||||
|
_ = b.sink.SendText(fmt.Sprintf("%s: %s\r\n", b.labelTX, text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ConsoleBroker) flushPendingTX() {
|
||||||
|
if b.pendingTX == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.emitTX(b.pendingTX.data)
|
||||||
|
b.pendingTX = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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) {
|
||||||
|
m.txQ <- txFrame{payload: append([]byte(nil), payload...), source: source, echo: requestEcho}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SerialMux) reader() {
|
||||||
|
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 {
|
||||||
|
m.broker.Enqueue(consoleEvent{kind: evRX, data: append([]byte(nil), buf[:n]...)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SerialMux) writer() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.done:
|
||||||
|
return
|
||||||
|
case f := <-m.txQ:
|
||||||
|
if _, err := m.port.Write(f.payload); err != nil {
|
||||||
|
serialLogger.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 {
|
||||||
|
m.broker.Enqueue(consoleEvent{kind: evTX, data: append([]byte(nil), f.payload...)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -171,8 +171,8 @@ function Terminal({
|
||||||
}, [instance]);
|
}, [instance]);
|
||||||
|
|
||||||
const sendLine = useCallback((line: string) => {
|
const sendLine = useCallback((line: string) => {
|
||||||
// Just send; echo/normalization handled elsewhere as you planned
|
// Just send; line ending/echo/normalization handled in serial.go
|
||||||
dataChannel.send(line + "\r\n"); // adjust CR/LF to taste
|
dataChannel.send(line);
|
||||||
}, [dataChannel]);
|
}, [dataChannel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ import notifications from "@/notifications";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
|
import Checkbox from "@components/Checkbox";
|
||||||
import Checkbox from "../../components/Checkbox";
|
import {SettingsItem} from "@components/SettingsItem";
|
||||||
import { SettingsItem } from "../../routes/devices.$id.settings";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,8 +24,8 @@ interface QuickButton {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomButtonSettings {
|
interface CustomButtonSettings {
|
||||||
baudRate: string;
|
baudRate: number;
|
||||||
dataBits: string;
|
dataBits: number;
|
||||||
stopBits: string;
|
stopBits: string;
|
||||||
parity: string;
|
parity: string;
|
||||||
terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR
|
terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR
|
||||||
|
|
@ -42,37 +41,12 @@ export function SerialButtons() {
|
||||||
const { setTerminalType, setTerminalLineMode } = useUiStore();
|
const { setTerminalType, setTerminalLineMode } = useUiStore();
|
||||||
|
|
||||||
// This will receive all JSON-RPC notifications (method + no id)
|
// This will receive all JSON-RPC notifications (method + no id)
|
||||||
const { send } = useJsonRpc((payload) => {
|
const { send } = useJsonRpc();
|
||||||
if (payload.method !== "serial.rx") return;
|
|
||||||
// if (paused) return;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const p = payload.params as any;
|
|
||||||
let chunk = "";
|
|
||||||
|
|
||||||
if (typeof p?.base64 === "string") {
|
|
||||||
try {
|
|
||||||
chunk = atob(p.base64);
|
|
||||||
} catch {
|
|
||||||
// ignore malformed base64
|
|
||||||
}
|
|
||||||
} else if (typeof p?.data === "string") {
|
|
||||||
// fallback if you ever send plain text
|
|
||||||
chunk = p.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chunk) return;
|
|
||||||
|
|
||||||
// Normalize CRLF for display
|
|
||||||
chunk = chunk.replace(/\r\n/g, "\n");
|
|
||||||
|
|
||||||
// setSerialResponse(prev => (prev + chunk).slice(-MAX_CHARS));
|
|
||||||
});
|
|
||||||
|
|
||||||
// extension config (buttons + prefs)
|
// extension config (buttons + prefs)
|
||||||
const [buttonConfig, setButtonConfig] = useState<CustomButtonSettings>({
|
const [buttonConfig, setButtonConfig] = useState<CustomButtonSettings>({
|
||||||
baudRate: "9600",
|
baudRate: 9600,
|
||||||
dataBits: "8",
|
dataBits: 8,
|
||||||
stopBits: "1",
|
stopBits: "1",
|
||||||
parity: "none",
|
parity: "none",
|
||||||
terminator: {label: "CR (\\r)", value: "\r"},
|
terminator: {label: "CR (\\r)", value: "\r"},
|
||||||
|
|
@ -118,9 +92,8 @@ export function SerialButtons() {
|
||||||
const onClickButton = (btn: QuickButton) => {
|
const onClickButton = (btn: QuickButton) => {
|
||||||
|
|
||||||
const command = btn.command + btn.terminator.value;
|
const command = btn.command + btn.terminator.value;
|
||||||
const terminator = btn.terminator.value;
|
|
||||||
|
|
||||||
send("sendCustomCommand", { command, terminator }, (resp: JsonRpcResponse) => {
|
send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to send custom command: ${resp.error.data || "Unknown error"}`,
|
`Failed to send custom command: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
@ -273,7 +246,7 @@ export function SerialButtons() {
|
||||||
{ label: "115200", value: "115200" },
|
{ label: "115200", value: "115200" },
|
||||||
]}
|
]}
|
||||||
value={buttonConfig.baudRate}
|
value={buttonConfig.baudRate}
|
||||||
onChange={(e) => handleSerialButtonConfigChange("baudRate", e.target.value)}
|
onChange={(e) => handleSerialButtonConfigChange("baudRate", Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectMenuBasic
|
<SelectMenuBasic
|
||||||
|
|
@ -283,7 +256,7 @@ export function SerialButtons() {
|
||||||
{ label: "7", value: "7" },
|
{ label: "7", value: "7" },
|
||||||
]}
|
]}
|
||||||
value={buttonConfig.dataBits}
|
value={buttonConfig.dataBits}
|
||||||
onChange={(e) => handleSerialButtonConfigChange("dataBits", e.target.value)}
|
onChange={(e) => handleSerialButtonConfigChange("dataBits", Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectMenuBasic
|
<SelectMenuBasic
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue