Update backend to combine serial console and custom buttons

This commit is contained in:
Severin Müller 2025-10-02 21:34:19 +02:00
parent c2219d1d15
commit 2b6571de1f
5 changed files with 507 additions and 108 deletions

View File

@ -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
View File

@ -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)
}
})
}

395
serial_console_helpers.go Normal file
View File

@ -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...)})
}
}
}
}

View File

@ -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 (

View File

@ -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