mirror of https://github.com/jetkvm/kvm.git
Update backend to combine serial console and custom buttons
This commit is contained in:
parent
c2219d1d15
commit
2b6571de1f
|
|
@ -820,9 +820,9 @@ func rpcGetATXState() (ATXState, error) {
|
|||
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")
|
||||
err := sendCustomCommand(command, terminator)
|
||||
err := sendCustomCommand(command)
|
||||
if err != nil {
|
||||
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"}},
|
||||
"getATXState": {Func: rpcGetATXState},
|
||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command", "terminator"}},
|
||||
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}},
|
||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||
"getSerialButtonConfig": {Func: rpcGetSerialButtonConfig},
|
||||
"getSerialButtonConfig": {Func: rpcGetSerialButtonConfig},
|
||||
"setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}},
|
||||
"getSerialCommandHistory": {Func: rpcGetSerialCommandHistory},
|
||||
"setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}},
|
||||
|
|
|
|||
161
serial.go
161
serial.go
|
|
@ -18,6 +18,8 @@ import (
|
|||
const serialPortPath = "/dev/ttyS3"
|
||||
|
||||
var port serial.Port
|
||||
var serialMux *SerialMux
|
||||
var consoleBr *ConsoleBroker
|
||||
|
||||
func mountATXControl() error {
|
||||
_ = port.SetMode(defaultMode)
|
||||
|
|
@ -257,12 +259,10 @@ func setDCRestoreState(state int) error {
|
|||
|
||||
func mountSerialButtons() error {
|
||||
_ = port.SetMode(defaultMode)
|
||||
startSerialButtonsRxLoop(currentSession)
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmountSerialButtons() error {
|
||||
stopSerialButtonsRxLoop()
|
||||
_ = reopenSerialPort()
|
||||
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.Info().Str("Command", command).Msg("Sending custom command.")
|
||||
_, err := port.Write([]byte(terminator))
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to send terminator")
|
||||
return err
|
||||
}
|
||||
_, err = port.Write([]byte(command))
|
||||
_, err := port.Write([]byte(command))
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Str("Command", command).Msg("Failed to send serial command")
|
||||
return err
|
||||
|
|
@ -345,6 +340,18 @@ var defaultMode = &serial.Mode{
|
|||
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"
|
||||
|
||||
type Terminator struct {
|
||||
|
|
@ -362,8 +369,8 @@ type QuickButton struct {
|
|||
|
||||
// Mode describes a serial port configuration.
|
||||
type CustomButtonSettings struct {
|
||||
BaudRate string `json:"baudRate"` // The serial port bitrate (aka Baudrate)
|
||||
DataBits string `json:"dataBits"` // Size of the character (must be 5, 6, 7 or 8)
|
||||
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
|
||||
|
|
@ -374,44 +381,33 @@ type CustomButtonSettings struct {
|
|||
}
|
||||
|
||||
func getSerialSettings() (CustomButtonSettings, error) {
|
||||
config := CustomButtonSettings{
|
||||
BaudRate: strconv.Itoa(defaultMode.BaudRate),
|
||||
DataBits: strconv.Itoa(defaultMode.DataBits),
|
||||
Parity: "none",
|
||||
StopBits: "1",
|
||||
Terminator: Terminator{Label: "CR (\\r)", Value: "\r"},
|
||||
LineMode: true,
|
||||
HideSerialSettings: false,
|
||||
EnableEcho: false,
|
||||
Buttons: []QuickButton{},
|
||||
}
|
||||
|
||||
switch defaultMode.StopBits {
|
||||
case serial.OneStopBit:
|
||||
config.StopBits = "1"
|
||||
SerialConfig.StopBits = "1"
|
||||
case serial.OnePointFiveStopBits:
|
||||
config.StopBits = "1.5"
|
||||
SerialConfig.StopBits = "1.5"
|
||||
case serial.TwoStopBits:
|
||||
config.StopBits = "2"
|
||||
SerialConfig.StopBits = "2"
|
||||
}
|
||||
|
||||
switch defaultMode.Parity {
|
||||
case serial.NoParity:
|
||||
config.Parity = "none"
|
||||
SerialConfig.Parity = "none"
|
||||
case serial.OddParity:
|
||||
config.Parity = "odd"
|
||||
SerialConfig.Parity = "odd"
|
||||
case serial.EvenParity:
|
||||
config.Parity = "even"
|
||||
SerialConfig.Parity = "even"
|
||||
case serial.MarkParity:
|
||||
config.Parity = "mark"
|
||||
SerialConfig.Parity = "mark"
|
||||
case serial.SpaceParity:
|
||||
config.Parity = "space"
|
||||
SerialConfig.Parity = "space"
|
||||
}
|
||||
|
||||
file, err := os.Open(serialSettingsPath)
|
||||
if err != nil {
|
||||
logger.Debug().Msg("SerialButtons config file doesn't exist, using default")
|
||||
return config, err
|
||||
return SerialConfig, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
|
@ -419,9 +415,11 @@ func getSerialSettings() (CustomButtonSettings, error) {
|
|||
var loadedConfig CustomButtonSettings
|
||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed")
|
||||
return config, nil
|
||||
return SerialConfig, nil
|
||||
}
|
||||
|
||||
SerialConfig = loadedConfig // Update global config
|
||||
|
||||
return loadedConfig, nil
|
||||
}
|
||||
|
||||
|
|
@ -440,15 +438,6 @@ func setSerialSettings(newSettings CustomButtonSettings) error {
|
|||
return fmt.Errorf("failed to encode SerialButtons config: %w", err)
|
||||
}
|
||||
|
||||
baudRate, err := strconv.Atoi(newSettings.BaudRate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid baud rate: %v", err)
|
||||
}
|
||||
dataBits, err := strconv.Atoi(newSettings.DataBits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid data bits: %v", err)
|
||||
}
|
||||
|
||||
var stopBits serial.StopBits
|
||||
switch newSettings.StopBits {
|
||||
case "1":
|
||||
|
|
@ -477,14 +466,20 @@ func setSerialSettings(newSettings CustomButtonSettings) error {
|
|||
return fmt.Errorf("invalid parity: %s", newSettings.Parity)
|
||||
}
|
||||
serialPortMode = &serial.Mode{
|
||||
BaudRate: baudRate,
|
||||
DataBits: dataBits,
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -510,7 +505,28 @@ 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 := 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
|
||||
}
|
||||
|
||||
|
|
@ -519,33 +535,44 @@ func handleSerialChannel(d *webrtc.DataChannel) {
|
|||
Uint16("data_channel_id", *d.ID()).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
|
||||
}
|
||||
}
|
||||
}()
|
||||
// go func() {
|
||||
// buf := make([]byte, 1024)
|
||||
// for {
|
||||
// n, err := port.Read(buf)
|
||||
// if err != nil {
|
||||
// if err != io.EOF {
|
||||
// scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
|
||||
// }
|
||||
// break
|
||||
// }
|
||||
// err = d.Send(buf[:n])
|
||||
// if err != nil {
|
||||
// scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
// Plug the terminal sink into the broker
|
||||
if consoleBr != nil {
|
||||
consoleBr.SetSink(dataChannelSink{d: d})
|
||||
_ = d.SendText("RX: [serial attached]\r\n")
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
_, err := port.Write(msg.Data)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
|
||||
}
|
||||
payload := append(msg.Data, []byte(SerialConfig.Terminator.Value)...)
|
||||
// requestEcho=true — the mux will honor it only if EnableEcho is on
|
||||
serialMux.Enqueue(payload, "webrtc", true)
|
||||
})
|
||||
|
||||
d.OnError(func(err error) {
|
||||
|
|
@ -554,5 +581,9 @@ func handleSerialChannel(d *webrtc.DataChannel) {
|
|||
|
||||
d.OnClose(func() {
|
||||
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]);
|
||||
|
||||
const sendLine = useCallback((line: string) => {
|
||||
// Just send; echo/normalization handled elsewhere as you planned
|
||||
dataChannel.send(line + "\r\n"); // adjust CR/LF to taste
|
||||
// Just send; line ending/echo/normalization handled in serial.go
|
||||
dataChannel.send(line);
|
||||
}, [dataChannel]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ import notifications from "@/notifications";
|
|||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { useUiStore } from "@/hooks/stores";
|
||||
|
||||
import Checkbox from "../../components/Checkbox";
|
||||
import { SettingsItem } from "../../routes/devices.$id.settings";
|
||||
import Checkbox from "@components/Checkbox";
|
||||
import {SettingsItem} from "@components/SettingsItem";
|
||||
|
||||
|
||||
|
||||
|
|
@ -25,8 +24,8 @@ interface QuickButton {
|
|||
}
|
||||
|
||||
interface CustomButtonSettings {
|
||||
baudRate: string;
|
||||
dataBits: string;
|
||||
baudRate: number;
|
||||
dataBits: number;
|
||||
stopBits: string;
|
||||
parity: string;
|
||||
terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR
|
||||
|
|
@ -42,37 +41,12 @@ export function SerialButtons() {
|
|||
const { setTerminalType, setTerminalLineMode } = useUiStore();
|
||||
|
||||
// This will receive all JSON-RPC notifications (method + no id)
|
||||
const { send } = useJsonRpc((payload) => {
|
||||
if (payload.method !== "serial.rx") return;
|
||||
// if (paused) return;
|
||||
|
||||
// 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));
|
||||
});
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
// extension config (buttons + prefs)
|
||||
const [buttonConfig, setButtonConfig] = useState<CustomButtonSettings>({
|
||||
baudRate: "9600",
|
||||
dataBits: "8",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: "1",
|
||||
parity: "none",
|
||||
terminator: {label: "CR (\\r)", value: "\r"},
|
||||
|
|
@ -118,9 +92,8 @@ export function SerialButtons() {
|
|||
const onClickButton = (btn: QuickButton) => {
|
||||
|
||||
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) {
|
||||
notifications.error(
|
||||
`Failed to send custom command: ${resp.error.data || "Unknown error"}`,
|
||||
|
|
@ -273,7 +246,7 @@ export function SerialButtons() {
|
|||
{ label: "115200", value: "115200" },
|
||||
]}
|
||||
value={buttonConfig.baudRate}
|
||||
onChange={(e) => handleSerialButtonConfigChange("baudRate", e.target.value)}
|
||||
onChange={(e) => handleSerialButtonConfigChange("baudRate", Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
|
|
@ -283,7 +256,7 @@ export function SerialButtons() {
|
|||
{ label: "7", value: "7" },
|
||||
]}
|
||||
value={buttonConfig.dataBits}
|
||||
onChange={(e) => handleSerialButtonConfigChange("dataBits", e.target.value)}
|
||||
onChange={(e) => handleSerialButtonConfigChange("dataBits", Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
|
|
|
|||
Loading…
Reference in New Issue