kvm/serial.go

637 lines
15 KiB
Go

package kvm
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
)
const serialPortPath = "/dev/ttyS3"
var port serial.Port
var serialMux *SerialMux
var consoleBroker *ConsoleBroker
func mountATXControl() error {
_ = port.SetMode(defaultMode)
go runATXControl()
return nil
}
func unmountATXControl() error {
_ = reopenSerialPort()
return nil
}
var (
ledHDDState bool
ledPWRState bool
btnRSTState bool
btnPWRState bool
)
func runATXControl() {
scopedLogger := serialLogger.With().Str("service", "atx_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Each line should be 4 binary digits + newline
if len(line) != 5 {
scopedLogger.Warn().Int("length", len(line)).Msg("Invalid line length")
continue
}
// Parse new states
newLedHDDState := line[0] == '0'
newLedPWRState := line[1] == '0'
newBtnRSTState := line[2] == '1'
newBtnPWRState := line[3] == '1'
if currentSession != nil {
writeJSONRPCEvent("atxState", ATXState{
Power: newLedPWRState,
HDD: newLedHDDState,
}, currentSession)
}
if newLedHDDState != ledHDDState ||
newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState {
scopedLogger.Debug().
Bool("hdd", newLedHDDState).
Bool("pwr", newLedPWRState).
Bool("rst", newBtnRSTState).
Bool("pwr", newBtnPWRState).
Msg("Status changed")
// Update states
ledHDDState = newLedHDDState
ledPWRState = newLedPWRState
btnRSTState = newBtnRSTState
btnPWRState = newBtnPWRState
}
}
}
func pressATXPowerButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_PWR_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_PWR_OFF\n"))
if err != nil {
return err
}
return nil
}
func pressATXResetButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_RST_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_RST_OFF\n"))
if err != nil {
return err
}
return nil
}
func mountDCControl() error {
_ = port.SetMode(defaultMode)
registerDCMetrics()
go runDCControl()
return nil
}
func unmountDCControl() error {
_ = reopenSerialPort()
return nil
}
var dcState DCPowerState
func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port)
hasRestoreFeature := false
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) == 5 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension with restore feature")
hasRestoreFeature = true
} else if len(parts) == 4 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension without restore feature")
hasRestoreFeature = false
} else {
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue
}
// Parse new states
powerState, err := strconv.Atoi(parts[0])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power state")
continue
}
dcState.IsOn = powerState == 1
if hasRestoreFeature {
restoreState, err := strconv.Atoi(parts[4])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid restore state")
continue
}
dcState.RestoreState = restoreState
} else {
// -1 means not supported
dcState.RestoreState = -1
}
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
continue
}
volts := milliVolts / 1000 // Convert mV to V
milliAmps, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid current")
continue
}
amps := milliAmps / 1000 // Convert mA to A
milliWatts, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power")
continue
}
watts := milliWatts / 1000 // Convert mW to W
dcState.Voltage = volts
dcState.Current = amps
dcState.Power = watts
// Update Prometheus metrics
updateDCMetrics(dcState)
if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession)
}
}
}
func setDCPowerState(on bool) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "PWR_OFF\n"
if on {
command = "PWR_ON\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
func setDCRestoreState(state int) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "RESTORE_MODE_OFF\n"
switch state {
case 1:
command = "RESTORE_MODE_ON\n"
case 2:
command = "RESTORE_MODE_LAST_STATE\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
func mountSerialButtons() error {
_ = port.SetMode(defaultMode)
return nil
}
func unmountSerialButtons() error {
_ = reopenSerialPort()
return nil
}
func sendCustomCommand(command string) error {
scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger()
scopedLogger.Info().Str("Command", command).Msg("Sending custom command.")
scopedLogger.Info().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,
Parity: serial.NoParity,
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",
PreserveANSI: true,
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"
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.Debug().Msg("SerialButtons config 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("SerialButtons config 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 "carret":
normalizeMode = ModeCaret
case "names":
normalizeMode = ModeNames
case "hex":
normalizeMode = ModeHex
default:
normalizeMode = ModeNames
}
var crlfMode CRLFMode
switch serialConfig.NormalizeLineEnd {
case "keep":
crlfMode = CRLFAsIs
case "lf":
crlfMode = CRLF_LF
case "cr":
crlfMode = CRLF_CR
case "crlf":
crlfMode = CRLF_CRLF
case "lfcr":
crlfMode = CRLF_LFCR
default:
crlfMode = CRLFAsIs
}
if consoleBroker != nil {
norm := NormOptions{
Mode: normalizeMode, CRLF: crlfMode, TabRender: 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 SerialButtons config 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 SerialButtons config: %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 "carret":
normalizeMode = ModeCaret
case "names":
normalizeMode = ModeNames
case "hex":
normalizeMode = ModeHex
default:
normalizeMode = ModeNames
}
var crlfMode CRLFMode
switch serialConfig.NormalizeLineEnd {
case "keep":
crlfMode = CRLFAsIs
case "lf":
crlfMode = CRLF_LF
case "cr":
crlfMode = CRLF_CR
case "crlf":
crlfMode = CRLF_CRLF
case "lfcr":
crlfMode = CRLF_LFCR
default:
crlfMode = CRLFAsIs
}
if consoleBroker != nil {
norm := NormOptions{
Mode: normalizeMode, CRLF: crlfMode, TabRender: 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 {
case "atx-power":
_ = mountATXControl()
case "dc-power":
_ = mountDCControl()
}
}
func reopenSerialPort() error {
if port != nil {
port.Close()
}
var err error
port, err = serial.Open(serialPortPath, defaultMode)
if err != nil {
serialLogger.Error().
Err(err).
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: ModeNames, CRLF: CRLF_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(dataChannel *webrtc.DataChannel) {
scopedLogger := serialLogger.With().
Uint16("data_channel_id", *dataChannel.ID()).Str("service", "serial terminal channel").Logger()
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")
}
})
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
scopedLogger.Info().Bytes("Data:", msg.Data).Msg("Sending data to serial mux")
scopedLogger.Info().Msgf("Sending data to serial mux: %q", msg.Data)
if serialMux == nil {
return
}
// requestEcho=true — the mux will honor it only if EnableEcho is on
serialMux.Enqueue(msg.Data, "webrtc", true)
})
dataChannel.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Serial channel error")
})
dataChannel.OnClose(func() {
scopedLogger.Info().Msg("Serial channel closed")
if consoleBroker != nil {
consoleBroker.SetSink(nil)
}
})
}