From b8d4464904d5ac01ed52deaf74979691886be88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20M=C3=BCller?= Date: Mon, 22 Sep 2025 17:10:58 +0200 Subject: [PATCH 1/8] Create new extension "Serial Buttons" --- jsonrpc.go | 58 +++ .../components/extensions/SerialButtons.tsx | 353 ++++++++++++++++++ .../components/popovers/ExtensionPopover.tsx | 9 + 3 files changed, 420 insertions(+) create mode 100644 ui/src/components/extensions/SerialButtons.tsx diff --git a/jsonrpc.go b/jsonrpc.go index 82b12d04b..552779ae2 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -872,6 +872,62 @@ func rpcSetSerialSettings(settings SerialSettings) error { return nil } +type QuickButton struct { + Id string `json:"id"` // uuid-ish + Label string `json:"label"` // shown on the button + Command string `json:"command"` // raw command to send (without auto-terminator) + Sort int `json:"sort"` // for stable ordering +} + +type SerialButtonConfig struct { + Buttons []QuickButton `json:"buttons"` // slice of QuickButton + Terminator string `json:"terminator"` // CR/CRLF/None + HideSerialSettings bool `json:"hideSerialSettings"` // lowercase `bool` +} + +func rpcGetSerialButtonConfig() (SerialButtonConfig, error) { + config := SerialButtonConfig{ + Buttons: []QuickButton{}, + Terminator: "\r", + HideSerialSettings: false, + } + + file, err := os.Open("/userdata/serialButtons_config.json") + if err != nil { + logger.Debug().Msg("SerialButtons config file doesn't exist, using default") + return config, nil + } + defer file.Close() + + // load and merge the default config with the user config + var loadedConfig SerialButtonConfig + if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { + logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed") + return config, nil + } + + return loadedConfig, nil +} + +func rpcSetSerialButtonConfig(config SerialButtonConfig) error { + + logger.Trace().Str("path", "/userdata/serialButtons_config.json").Msg("Saving config") + + file, err := os.Create("/userdata/serialButtons_config.json") + 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(config); err != nil { + return fmt.Errorf("failed to encode SerialButtons config: %w", err) + } + + return nil +} + func rpcGetUsbDevices() (usbgadget.Devices, error) { return *config.UsbDevices, nil } @@ -1123,6 +1179,8 @@ var rpcHandlers = map[string]RPCHandler{ "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "getSerialSettings": {Func: rpcGetSerialSettings}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, + "getSerialButtonConfig": {Func: rpcGetSerialButtonConfig}, + "setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}}, "getUsbDevices": {Func: rpcGetUsbDevices}, "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, diff --git a/ui/src/components/extensions/SerialButtons.tsx b/ui/src/components/extensions/SerialButtons.tsx new file mode 100644 index 000000000..a4f01898c --- /dev/null +++ b/ui/src/components/extensions/SerialButtons.tsx @@ -0,0 +1,353 @@ +import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave } from "react-icons/lu"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@components/Button"; +import Card from "@components/Card"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import notifications from "@/notifications"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import { InputFieldWithLabel } from "@components/InputField"; + +/** ============== Types ============== */ + +interface SerialSettings { + baudRate: string; + dataBits: string; + stopBits: string; + parity: string; +} + +interface QuickButton { + id: string; // uuid-ish + label: string; // shown on the button + command: string; // raw command to send (without auto-terminator) + sort: number; // for stable ordering +} + +interface ButtonConfig { + buttons: QuickButton[]; + terminator: string; // CR/CRLF/None + hideSerialSettings: boolean; +} + +/** ============== Component ============== */ + +export function SerialButtons() { + const { send } = useJsonRpc(); + + // serial settings (same as SerialConsole) + const [serialSettings, setSerialSettings] = useState({ + baudRate: "9600", + dataBits: "8", + stopBits: "1", + parity: "none", + }); + + // extension config (buttons + prefs) + const [buttonConfig, setButtonConfig] = useState({ + buttons: [], + terminator: "", + hideSerialSettings: false, +}); + + // editor modal state + const [editorOpen, setEditorOpen] = useState(null); + const [draftLabel, setDraftLabel] = useState(""); + const [draftCmd, setDraftCmd] = useState(""); + + // load serial settings like SerialConsole + useEffect(() => { + send("getSerialSettings", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + `Failed to get serial settings: ${resp.error.data || "Unknown error"}`, + ); + return; + } + setSerialSettings(resp.result as SerialSettings); + }); + + send("getSerialButtonConfig", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + `Failed to get button config: ${resp.error.data || "Unknown error"}`, + ); + return; + } + + const cfg = resp.result as ButtonConfig; + console.log("loaded button config: "); + console.log(cfg); + setButtonConfig(resp.result as ButtonConfig); + }); + + console.log("loaded loaded settings through effect."); + + }, [send]); + + const handleSerialSettingChange = (setting: keyof SerialSettings, value: string) => { + const newSettings = { ...serialSettings, [setting]: value }; + send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update serial settings: ${resp.error.data || "Unknown error"}`); + return; + } + setSerialSettings(newSettings); + }); + }; + + const handleSerialButtonConfigChange = (config: keyof ButtonConfig, value: unknown) => { + const newButtonConfig = { ...buttonConfig, [config]: value }; + send("setSerialButtonConfig", { config: newButtonConfig }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update button config: ${resp.error.data || "Unknown error"}`); + return; + } + // setButtonConfig(newButtonConfig); + }); + }; + + /** build final string to send: + * if the user's button command already contains a terminator, we don't append the selected terminator safely + */ + const buildOutgoing = (raw: string): string => { + const t = buttonConfig.terminator ?? ""; + return raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t; + }; + + const onClickButton = async (btn: QuickButton) => { + buildOutgoing(btn.command); + // Try to send via backend method + }; + + /** CRUD helpers */ + const addNew = () => { + setEditorOpen({ id: undefined }); + setDraftLabel(""); + setDraftCmd(""); + }; + + const editBtn = (btn: QuickButton) => { + setEditorOpen({ id: btn.id }); + setDraftLabel(btn.label); + setDraftCmd(btn.command); + }; + + // const removeBtn = async (id: string) => { + // const next = { ...buttonConfig, buttons: buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) }; + // // await setButtonConfig(next); + // setEditorOpen(null); + // }; + + const saveDraft = () => { + const label = draftLabel.trim() || "Unnamed"; + const command = draftCmd.trim(); + if (!command) { + notifications.error("Command cannot be empty."); + return; + } + + const isEdit = editorOpen?.id; + const nextButtons = isEdit + ? buttonConfig.buttons.map(b => (b.id === isEdit ? { ...b, label, command } : b)) + : [...buttonConfig.buttons, { id: genId(), label, command, sort: buttonConfig.buttons.length }]; + + handleSerialButtonConfigChange("buttons", stableSort(nextButtons) ); + setEditorOpen(null); + }; + + /** simple reordering: alphabetical by sort, then label */ + const sortedButtons = useMemo(() => stableSort(buttonConfig.buttons), [buttonConfig.buttons]); + + return ( +
+ + + +
+ {/* Top actions */} +
+
+ + {/* Serial settings (collapsible) */} + {!buttonConfig.hideSerialSettings && ( + <> +
+
+ handleSerialSettingChange("baudRate", e.target.value)} + /> + + handleSerialSettingChange("dataBits", e.target.value)} + /> + + handleSerialSettingChange("stopBits", e.target.value)} + /> + + handleSerialSettingChange("parity", e.target.value)} + /> +
+ handleSerialButtonConfigChange("terminator", e.target.value)} + /> +
+ + )} + + {/* Buttons grid */} +
+ {sortedButtons.map((btn) => ( +
+
+
+
+ ))} + {sortedButtons.length === 0 && ( +
No buttons yet. Click “Add Button”.
+ )} +
+ + {/* Editor drawer/modal (inline lightweight) */} + {editorOpen && ( +
+
+ +
{editorOpen.id ? "Edit Button" : "New Button"}
+
+
+
+ { + setDraftLabel(e.target.value); + }} + /> +
+
+ { + setDraftCmd(e.target.value); + }} + /> +
+ The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent. +
+
+
+
+
+
+ )} +
+
+
+ ); +} + +/** ============== helpers ============== */ + +function pretty(s: string) { + return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n"); +} +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)); +} + diff --git a/ui/src/components/popovers/ExtensionPopover.tsx b/ui/src/components/popovers/ExtensionPopover.tsx index f36c05033..dc57502d4 100644 --- a/ui/src/components/popovers/ExtensionPopover.tsx +++ b/ui/src/components/popovers/ExtensionPopover.tsx @@ -7,6 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { SerialConsole } from "@components/extensions/SerialConsole"; +import { SerialButtons } from "@components/extensions/SerialButtons"; import { Button } from "@components/Button"; import notifications from "@/notifications"; @@ -36,6 +37,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [ description: "Access your serial console extension", icon: LuTerminal, }, + { + id: "serial-buttons", + name: "Serial Buttons", + description: "Send custom serial signals by buttons", + icon: LuTerminal, + }, ]; export default function ExtensionPopover() { @@ -76,6 +83,8 @@ export default function ExtensionPopover() { return ; case "serial-console": return ; + case "serial-buttons": + return ; default: return null; } From d8f670fcbae0f17137f45095db20ddfcff5d41a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20M=C3=BCller?= Date: Tue, 23 Sep 2025 16:49:45 +0200 Subject: [PATCH 2/8] Add backend to send custom commands --- jsonrpc.go | 14 ++++++ serial.go | 27 +++++++++++ .../components/extensions/SerialButtons.tsx | 45 ++++++++++--------- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index e448f9528..a55a1c712 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -758,6 +758,8 @@ func rpcSetActiveExtension(extensionId string) error { _ = unmountATXControl() case "dc-power": _ = unmountDCControl() + case "serial-buttons": + _ = unmountSerialButtons() } config.ActiveExtension = extensionId if err := SaveConfig(); err != nil { @@ -768,6 +770,8 @@ func rpcSetActiveExtension(extensionId string) error { _ = mountATXControl() case "dc-power": _ = mountDCControl() + case "serial-buttons": + _ = mountSerialButtons() } return nil } @@ -802,6 +806,15 @@ func rpcGetATXState() (ATXState, error) { return state, nil } +func rpcSendCustomCommand(command string) error { + logger.Info().Str("Command", command).Msg("JSONRPC: Sending custom serial command") + err := sendCustomCommand(command) + if err != nil { + return fmt.Errorf("failed to set DC power state: %w", err) + } + return nil +} + type SerialSettings struct { BaudRate string `json:"baudRate"` DataBits string `json:"dataBits"` @@ -1296,6 +1309,7 @@ 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"}}, "getSerialSettings": {Func: rpcGetSerialSettings}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "getSerialButtonConfig": {Func: rpcGetSerialButtonConfig}, diff --git a/serial.go b/serial.go index 5439d135a..85165f9b6 100644 --- a/serial.go +++ b/serial.go @@ -251,6 +251,33 @@ func setDCRestoreState(state int) error { 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").Logger() + scopedLogger.Info().Str("Command", command).Msg("Sending custom command.") + _, err := port.Write([]byte("\n")) + if err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to send serial output \\n") + return err + } + _, err = port.Write([]byte(command)) + if err != nil { + scopedLogger.Warn().Err(err).Str("line", command).Msg("Failed to send serial output") + return err + } + return nil +} + var defaultMode = &serial.Mode{ BaudRate: 115200, DataBits: 8, diff --git a/ui/src/components/extensions/SerialButtons.tsx b/ui/src/components/extensions/SerialButtons.tsx index a4f01898c..aa47428db 100644 --- a/ui/src/components/extensions/SerialButtons.tsx +++ b/ui/src/components/extensions/SerialButtons.tsx @@ -76,15 +76,10 @@ export function SerialButtons() { return; } - const cfg = resp.result as ButtonConfig; - console.log("loaded button config: "); - console.log(cfg); setButtonConfig(resp.result as ButtonConfig); }); - console.log("loaded loaded settings through effect."); - - }, [send]); + }); const handleSerialSettingChange = (setting: keyof SerialSettings, value: string) => { const newSettings = { ...serialSettings, [setting]: value }; @@ -104,21 +99,27 @@ export function SerialButtons() { notifications.error(`Failed to update button config: ${resp.error.data || "Unknown error"}`); return; } - // setButtonConfig(newButtonConfig); + setButtonConfig(newButtonConfig); }); }; - /** build final string to send: - * if the user's button command already contains a terminator, we don't append the selected terminator safely - */ - const buildOutgoing = (raw: string): string => { + const onClickButton = (btn: QuickButton) => { + + /** build final string to send: + * if the user's button command already contains a terminator, we don't append the selected terminator safely + */ + const raw = btn.command; const t = buttonConfig.terminator ?? ""; - return raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t; - }; + const command = raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t; + + send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + `Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, + ); + } + }); - const onClickButton = async (btn: QuickButton) => { - buildOutgoing(btn.command); - // Try to send via backend method }; /** CRUD helpers */ @@ -134,11 +135,11 @@ export function SerialButtons() { setDraftCmd(btn.command); }; - // const removeBtn = async (id: string) => { - // const next = { ...buttonConfig, buttons: buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) }; - // // await setButtonConfig(next); - // setEditorOpen(null); - // }; + const removeBtn = (id: string) => { + const nextButtons = buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) ; + handleSerialButtonConfigChange("buttons", stableSort(nextButtons) ); + setEditorOpen(null); + }; const saveDraft = () => { const label = draftLabel.trim() || "Unnamed"; @@ -327,7 +328,7 @@ export function SerialButtons() { theme="danger" LeadingIcon={LuTrash2} text="Delete" - // onClick={() => removeBtn(editorOpen!.id)} + onClick={() => removeBtn(editorOpen.id!)} aria-label={`Delete ${draftLabel}`} />)} From 67e9136b039e156ab34da197e650ec2d39797b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20M=C3=BCller?= Date: Tue, 23 Sep 2025 22:11:54 +0200 Subject: [PATCH 3/8] Add order buttons and response field --- jsonrpc.go | 6 +- serial.go | 55 +++++- .../components/extensions/SerialButtons.tsx | 170 ++++++++++++++++-- 3 files changed, 212 insertions(+), 19 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index a55a1c712..6e4276d27 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -807,10 +807,10 @@ func rpcGetATXState() (ATXState, error) { } func rpcSendCustomCommand(command string) error { - logger.Info().Str("Command", command).Msg("JSONRPC: Sending custom serial command") + logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command") err := sendCustomCommand(command) if err != nil { - return fmt.Errorf("failed to set DC power state: %w", err) + return fmt.Errorf("failed to send custom command in jsonrpc: %w", err) } return nil } @@ -917,6 +917,7 @@ type SerialButtonConfig struct { Buttons []QuickButton `json:"buttons"` // slice of QuickButton Terminator string `json:"terminator"` // CR/CRLF/None HideSerialSettings bool `json:"hideSerialSettings"` // lowercase `bool` + HideSerialResponse bool `json:"hideSerialResponse"` // lowercase `bool` } func rpcGetSerialButtonConfig() (SerialButtonConfig, error) { @@ -924,6 +925,7 @@ func rpcGetSerialButtonConfig() (SerialButtonConfig, error) { Buttons: []QuickButton{}, Terminator: "\r", HideSerialSettings: false, + HideSerialResponse: true, } file, err := os.Open("/userdata/serialButtons_config.json") diff --git a/serial.go b/serial.go index 85165f9b6..9d77e5bcc 100644 --- a/serial.go +++ b/serial.go @@ -2,6 +2,7 @@ package kvm import ( "bufio" + "encoding/base64" "io" "strconv" "strings" @@ -253,17 +254,67 @@ func setDCRestoreState(state int) error { func mountSerialButtons() error { _ = port.SetMode(defaultMode) - + startSerialButtonsRxLoop(currentSession) return nil } func unmountSerialButtons() error { + stopSerialButtonsRxLoop() _ = reopenSerialPort() return nil } +// ---- Serial Buttons RX fan-out (JSON-RPC events) ---- +var serialButtonsRXStopCh chan struct{} + +func startSerialButtonsRxLoop(session *Session) { + scopedLogger := serialLogger.With().Str("service", "custom_buttons_rx").Logger() + scopedLogger.Debug().Msg("Attempting to start RX reader.") + // Stop previous loop if running + if serialButtonsRXStopCh != nil { + close(serialButtonsRXStopCh) + } + serialButtonsRXStopCh = make(chan struct{}) + + go func() { + buf := make([]byte, 4096) + scopedLogger.Debug().Msg("Starting loop") + + for { + select { + case <-serialButtonsRXStopCh: + return + default: + n, err := port.Read(buf) + if err != nil { + if err != io.EOF { + scopedLogger.Debug().Err(err).Msg("serial RX read error") + } + time.Sleep(50 * time.Millisecond) + continue + } + if n == 0 || currentSession == nil { + continue + } + // Safe for any bytes: wrap in Base64 + b64 := base64.StdEncoding.EncodeToString(buf[:n]) + writeJSONRPCEvent("serial.rx", map[string]any{ + "base64": b64, + }, currentSession) + } + } + }() +} + +func stopSerialButtonsRxLoop() { + if serialButtonsRXStopCh != nil { + close(serialButtonsRXStopCh) + serialButtonsRXStopCh = nil + } +} + func sendCustomCommand(command string) error { - scopedLogger := serialLogger.With().Str("service", "custom-buttons").Logger() + scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger() scopedLogger.Info().Str("Command", command).Msg("Sending custom command.") _, err := port.Write([]byte("\n")) if err != nil { diff --git a/ui/src/components/extensions/SerialButtons.tsx b/ui/src/components/extensions/SerialButtons.tsx index aa47428db..c67a8eae3 100644 --- a/ui/src/components/extensions/SerialButtons.tsx +++ b/ui/src/components/extensions/SerialButtons.tsx @@ -1,5 +1,5 @@ -import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave } from "react-icons/lu"; -import { useEffect, useMemo, useState } from "react"; +import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCirclePause, LuCirclePlay } from "react-icons/lu"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@components/Button"; import Card from "@components/Card"; @@ -8,6 +8,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { InputFieldWithLabel } from "@components/InputField"; +import { TextAreaWithLabel } from "@components/TextArea"; /** ============== Types ============== */ @@ -29,12 +30,42 @@ interface ButtonConfig { buttons: QuickButton[]; terminator: string; // CR/CRLF/None hideSerialSettings: boolean; + hideSerialResponse: boolean; } /** ============== Component ============== */ export function SerialButtons() { - const { send } = useJsonRpc(); + // 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 MAX_CHARS = 50_000; // serial settings (same as SerialConsole) const [serialSettings, setSerialSettings] = useState({ @@ -49,12 +80,16 @@ export function SerialButtons() { buttons: [], terminator: "", hideSerialSettings: false, + hideSerialResponse: true, }); // editor modal state const [editorOpen, setEditorOpen] = useState(null); const [draftLabel, setDraftLabel] = useState(""); const [draftCmd, setDraftCmd] = useState(""); + const [serialResponse, setSerialResponse] = useState(""); + const [paused, setPaused] = useState(false); + const taRef = useRef(null); // load serial settings like SerialConsole useEffect(() => { @@ -101,8 +136,15 @@ export function SerialButtons() { } setButtonConfig(newButtonConfig); }); + setButtonConfig(newButtonConfig); }; + useEffect(() => { + if (buttonConfig.hideSerialResponse) return; + const el = taRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [serialResponse, buttonConfig.hideSerialResponse]); + const onClickButton = (btn: QuickButton) => { /** build final string to send: @@ -141,6 +183,48 @@ export function SerialButtons() { setEditorOpen(null); }; + const moveUpBtn = (id: string) => { + // Make a copy so we don't mutate state directly + const newButtons = [...buttonConfig.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 })); + handleSerialButtonConfigChange("buttons", stableSort(nextButtons) ); + setEditorOpen(null); + }; + + const moveDownBtn = (id: string) => { + // Make a copy so we don't mutate state directly + const newButtons = [...buttonConfig.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 })); + handleSerialButtonConfigChange("buttons", stableSort(nextButtons) ); + setEditorOpen(null); + }; + const saveDraft = () => { const label = draftLabel.trim() || "Unnamed"; const command = draftCmd.trim(); @@ -176,7 +260,7 @@ export function SerialButtons() { size="SM" theme="primary" LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff} - text={buttonConfig.hideSerialSettings ? "Show Serial Settings" : "Hide Serial Settings"} + text={buttonConfig.hideSerialSettings ? "Show Settings" : "Hide Settings"} onClick={() => handleSerialButtonConfigChange("hideSerialSettings", !buttonConfig.hideSerialSettings )} /> + + + + ); +} + +// ---------- 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 } = useCommandHistory(); + + const results = useMemo(() => search(revQuery), [revQuery, search]); + + useEffect(() => { setSel(0); }, [results]); + + const cmdInputRef = React.useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + 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 ( +
+
+ CMD + { setCmd(e.target.value); resetTraversal(); }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="font-mono" + /> +
+ + {/* Reverse search controls */} + {revOpen && ( +
+
+ Search + 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" + /> +
+ { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }} + onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}} + /> +
+ )} +
+ ); +}; + +export default CommandInput; diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index ba3e667c1..75c57407b 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import "react-simple-keyboard/build/css/index.css"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useCallback } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -10,9 +10,11 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; import { cx } from "@/cva.config"; import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores"; +import { CommandInput } from "@/components/CommandInput"; import { Button } from "./Button"; + const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2"); // Terminal theme configuration @@ -65,13 +67,20 @@ function Terminal({ readonly dataChannel: RTCDataChannel; readonly type: AvailableTerminalTypes; }) { - const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); + const { terminalLineMode, terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); const isTerminalTypeEnabled = useMemo(() => { + console.log("Terminal type:", terminalType, "Checking against:", type); return terminalType == type; }, [terminalType, type]); + useEffect(() => { + if (!instance) return; + instance.options.disableStdin = !terminalLineMode; + instance.options.cursorStyle = terminalLineMode ? "bar" : "block"; + }, [instance, terminalLineMode]); + useEffect(() => { setTimeout(() => { setDisableVideoFocusTrap(isTerminalTypeEnabled); @@ -161,6 +170,11 @@ 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 + }, [dataChannel]); + return (
e.stopPropagation()} @@ -199,7 +213,14 @@ function Terminal({
-
+
+ {terminalType == "serial" && terminalLineMode && ( + + )}
diff --git a/ui/src/components/extensions/SerialButtons.tsx b/ui/src/components/extensions/SerialButtons.tsx index c67a8eae3..d5c2d01b7 100644 --- a/ui/src/components/extensions/SerialButtons.tsx +++ b/ui/src/components/extensions/SerialButtons.tsx @@ -1,5 +1,5 @@ -import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCirclePause, LuCirclePlay } from "react-icons/lu"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCircleX, LuTerminal } from "react-icons/lu"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "@components/Button"; import Card from "@components/Card"; @@ -8,38 +8,43 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { InputFieldWithLabel } from "@components/InputField"; -import { TextAreaWithLabel } from "@components/TextArea"; +import { useUiStore } from "@/hooks/stores"; + +import Checkbox from "../../components/Checkbox"; +import { SettingsItem } from "../../routes/devices.$id.settings"; -/** ============== Types ============== */ -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 ButtonConfig { - buttons: QuickButton[]; - terminator: string; // CR/CRLF/None +interface CustomButtonSettings { + baudRate: string; + dataBits: string; + stopBits: string; + parity: string; + terminator: {label: string, value: string}; // None/CR/LF/CRLF/LFCR + lineMode: boolean; hideSerialSettings: boolean; - hideSerialResponse: boolean; + enableEcho: boolean; // future use + buttons: QuickButton[]; } /** ============== Component ============== */ 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; + // if (paused) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const p = payload.params as any; @@ -61,48 +66,30 @@ export function SerialButtons() { // Normalize CRLF for display chunk = chunk.replace(/\r\n/g, "\n"); - setSerialResponse(prev => (prev + chunk).slice(-MAX_CHARS)); + // setSerialResponse(prev => (prev + chunk).slice(-MAX_CHARS)); }); - - const MAX_CHARS = 50_000; - - // serial settings (same as SerialConsole) - const [serialSettings, setSerialSettings] = useState({ + // extension config (buttons + prefs) + const [buttonConfig, setButtonConfig] = useState({ baudRate: "9600", dataBits: "8", stopBits: "1", parity: "none", + terminator: {label: "CR (\\r)", value: "\r"}, + lineMode: true, + hideSerialSettings: false, + enableEcho: false, + buttons: [], }); - // extension config (buttons + prefs) - const [buttonConfig, setButtonConfig] = useState({ - buttons: [], - terminator: "", - hideSerialSettings: false, - hideSerialResponse: true, -}); - // editor modal state const [editorOpen, setEditorOpen] = useState(null); const [draftLabel, setDraftLabel] = useState(""); const [draftCmd, setDraftCmd] = useState(""); - const [serialResponse, setSerialResponse] = useState(""); - const [paused, setPaused] = useState(false); - const taRef = useRef(null); + const [draftTerminator, setDraftTerminator] = useState({label: "CR (\\r)", value: "\r"}); // load serial settings like SerialConsole useEffect(() => { - send("getSerialSettings", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - `Failed to get serial settings: ${resp.error.data || "Unknown error"}`, - ); - return; - } - setSerialSettings(resp.result as SerialSettings); - }); - send("getSerialButtonConfig", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( @@ -111,57 +98,35 @@ export function SerialButtons() { return; } - setButtonConfig(resp.result as ButtonConfig); + setButtonConfig(resp.result as CustomButtonSettings); + setTerminalLineMode((resp.result as CustomButtonSettings).lineMode); }); - }); - - const handleSerialSettingChange = (setting: keyof SerialSettings, value: string) => { - const newSettings = { ...serialSettings, [setting]: value }; - send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(`Failed to update serial settings: ${resp.error.data || "Unknown error"}`); - return; - } - setSerialSettings(newSettings); - }); - }; + }, [send, setTerminalLineMode]); - const handleSerialButtonConfigChange = (config: keyof ButtonConfig, value: unknown) => { + const handleSerialButtonConfigChange = (config: keyof CustomButtonSettings, value: unknown) => { const newButtonConfig = { ...buttonConfig, [config]: value }; send("setSerialButtonConfig", { config: newButtonConfig }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to update button config: ${resp.error.data || "Unknown error"}`); return; } - setButtonConfig(newButtonConfig); }); setButtonConfig(newButtonConfig); }; - useEffect(() => { - if (buttonConfig.hideSerialResponse) return; - const el = taRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [serialResponse, buttonConfig.hideSerialResponse]); - const onClickButton = (btn: QuickButton) => { - /** build final string to send: - * if the user's button command already contains a terminator, we don't append the selected terminator safely - */ - const raw = btn.command; - const t = buttonConfig.terminator ?? ""; - const command = raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t; + const command = btn.command + btn.terminator.value; + const terminator = btn.terminator.value; - send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => { + send("sendCustomCommand", { command, terminator }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( - `Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, + `Failed to send custom command: ${resp.error.data || "Unknown error"}`, ); } }); - }; /** CRUD helpers */ @@ -169,12 +134,14 @@ export function SerialButtons() { setEditorOpen({ id: undefined }); setDraftLabel(""); setDraftCmd(""); + setDraftTerminator({label: "CR (\\r)", value: "\r"}); }; const editBtn = (btn: QuickButton) => { setEditorOpen({ id: btn.id }); setDraftLabel(btn.label); setDraftCmd(btn.command); + setDraftTerminator(btn.terminator); }; const removeBtn = (id: string) => { @@ -227,23 +194,29 @@ export function SerialButtons() { const saveDraft = () => { const label = draftLabel.trim() || "Unnamed"; - const command = draftCmd.trim(); + const command = draftCmd; if (!command) { notifications.error("Command cannot be empty."); return; } + const terminator = draftTerminator; + + // if editing, get current id, otherwise undefined => new button + const currentID = editorOpen?.id; - const isEdit = editorOpen?.id; - const nextButtons = isEdit - ? buttonConfig.buttons.map(b => (b.id === isEdit ? { ...b, label, command } : b)) - : [...buttonConfig.buttons, { id: genId(), label, command, sort: buttonConfig.buttons.length }]; + // either update existing or add new + // if new, assign next sort index + // if existing, keep sort index + const nextButtons = currentID + ? buttonConfig.buttons.map(b => (b.id === currentID ? { ...b, label, command } : b)) + : [...buttonConfig.buttons, { id: genId(), label, command, terminator, sort: buttonConfig.buttons.length }]; handleSerialButtonConfigChange("buttons", stableSort(nextButtons) ); setEditorOpen(null); }; /** simple reordering: alphabetical by sort, then label */ - const sortedButtons = useMemo(() => stableSort(buttonConfig.buttons), [buttonConfig.buttons]); + const sortedButtons = useMemo(() => buttonConfig.buttons, [buttonConfig.buttons]); return (
@@ -257,25 +230,28 @@ export function SerialButtons() { {/* Top actions */}

@@ -296,8 +272,8 @@ export function SerialButtons() { { label: "57600", value: "57600" }, { label: "115200", value: "115200" }, ]} - value={serialSettings.baudRate} - onChange={(e) => handleSerialSettingChange("baudRate", e.target.value)} + value={buttonConfig.baudRate} + onChange={(e) => handleSerialButtonConfigChange("baudRate", e.target.value)} /> handleSerialSettingChange("dataBits", e.target.value)} + value={buttonConfig.dataBits} + onChange={(e) => handleSerialButtonConfigChange("dataBits", e.target.value)} /> handleSerialSettingChange("stopBits", e.target.value)} + value={buttonConfig.stopBits} + onChange={(e) => handleSerialButtonConfigChange("stopBits", e.target.value)} /> handleSerialSettingChange("parity", e.target.value)} + value={buttonConfig.parity} + onChange={(e) => handleSerialButtonConfigChange("parity", e.target.value)} /> +
+ handleSerialButtonConfigChange("terminator", {label: e.target.selectedOptions[0].text, value: e.target.value})} + /> +
+ When sent, the selected line ending ({buttonConfig.terminator.label}) will be appended. +
+
+
+ { + handleSerialButtonConfigChange("lineMode", e.target.value === "line") + setTerminalLineMode(e.target.value === "line"); + }} + /> +
+ {buttonConfig.lineMode + ? "In Line Mode, input is sent when you press Enter in the input field." + : "In Raw Mode, input is sent immediately as you type in the console."} +
+
+
+
+ + { + handleSerialButtonConfigChange("enableEcho", e.target.checked); + }} + /> +
- handleSerialButtonConfigChange("terminator", e.target.value)} - />
)} @@ -382,7 +399,7 @@ export function SerialButtons() {
{editorOpen.id ? "Edit Button" : "New Button"}
-
+
- {buttonConfig.terminator != "" && ( + {draftTerminator.value != "" && (
- The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent. + When sent, the selected line ending ({draftTerminator.label}) will be appended.
)}
-
-
+
+
+
+
{editorOpen.id && ( <>
)} - {/* Serial response (collapsible) */} - {!buttonConfig.hideSerialResponse && ( - <> -
- setSerialResponse(e.target.value)} - placeholder="Will show the response recieved from the serial port." - /> -
-
- - )} @@ -481,10 +491,6 @@ export function SerialButtons() { } /** ============== helpers ============== */ - -function pretty(s: string) { - return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n"); -} function genId() { return "b_" + Math.random().toString(36).slice(2, 10); } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e5..1a6349784 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -69,6 +69,9 @@ export interface UIState { terminalType: AvailableTerminalTypes; setTerminalType: (type: UIState["terminalType"]) => void; + + terminalLineMode: boolean; + setTerminalLineMode: (enabled: boolean) => void; } export const useUiStore = create(set => ({ @@ -96,6 +99,9 @@ export const useUiStore = create(set => ({ isAttachedVirtualKeyboardVisible: true, setAttachedVirtualKeyboardVisibility: (enabled: boolean) => set({ isAttachedVirtualKeyboardVisible: enabled }), + + terminalLineMode: true, + setTerminalLineMode: (enabled: boolean) => set({ terminalLineMode: enabled }), })); export interface RTCState { @@ -465,7 +471,7 @@ export interface KeysDownState { keys: number[]; } -export type USBStates = +export type USBStates = | "configured" | "attached" | "not attached" From 2b6571de1f73ac58e419d0faa1d9b61792f37edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20M=C3=BCller?= Date: Thu, 2 Oct 2025 21:34:19 +0200 Subject: [PATCH 5/8] Update backend to combine serial console and custom buttons --- jsonrpc.go | 8 +- serial.go | 161 ++++--- serial_console_helpers.go | 395 ++++++++++++++++++ ui/src/components/Terminal.tsx | 4 +- .../components/extensions/SerialButtons.tsx | 47 +-- 5 files changed, 507 insertions(+), 108 deletions(-) create mode 100644 serial_console_helpers.go diff --git a/jsonrpc.go b/jsonrpc.go index 1e096a349..7cd62c2d7 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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"}}, diff --git a/serial.go b/serial.go index fbf3d965d..0d32d9c64 100644 --- a/serial.go +++ b/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) + } }) } diff --git a/serial_console_helpers.go b/serial_console_helpers.go new file mode 100644 index 000000000..aded0f8d0 --- /dev/null +++ b/serial_console_helpers.go @@ -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 // , , , … + 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...)}) + } + } + } +} diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index 75c57407b..7c0909956 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -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 ( diff --git a/ui/src/components/extensions/SerialButtons.tsx b/ui/src/components/extensions/SerialButtons.tsx index d5c2d01b7..43ff41971 100644 --- a/ui/src/components/extensions/SerialButtons.tsx +++ b/ui/src/components/extensions/SerialButtons.tsx @@ -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({ - 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))} /> handleSerialButtonConfigChange("dataBits", e.target.value)} + onChange={(e) => handleSerialButtonConfigChange("dataBits", Number(e.target.value))} /> Date: Thu, 9 Oct 2025 06:32:40 +0200 Subject: [PATCH 6/8] Update backend, implement pause function in terminal --- jsonrpc.go | 301 ++++------ serial.go | 303 +++++----- serial_console_helpers.go | 202 +++++-- ui/src/components/CommandInput.tsx | 27 +- ui/src/components/Terminal.tsx | 63 ++- .../components/extensions/SerialButtons.tsx | 473 ---------------- .../components/extensions/SerialConsole.tsx | 527 +++++++++++++++--- .../components/popovers/ExtensionPopover.tsx | 9 - ui/src/hooks/stores.ts | 18 +- 9 files changed, 1000 insertions(+), 923 deletions(-) delete mode 100644 ui/src/components/extensions/SerialButtons.tsx diff --git a/jsonrpc.go b/jsonrpc.go index 7cd62c2d7..4dfc766b6 100644 --- a/jsonrpc.go +++ b/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" @@ -829,103 +827,12 @@ func rpcSendCustomCommand(command string) error { return nil } -type SerialSettings struct { - BaudRate string `json:"baudRate"` - DataBits string `json:"dataBits"` - StopBits string `json:"stopBits"` - Parity string `json:"parity"` -} - 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 -} - -var serialPortMode = defaultMode - -func rpcSetSerialSettings(settings SerialSettings) error { - baudRate, err := strconv.Atoi(settings.BaudRate) - if err != nil { - return fmt.Errorf("invalid baud rate: %v", err) - } - dataBits, err := strconv.Atoi(settings.DataBits) - if err != nil { - return fmt.Errorf("invalid data bits: %v", 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) - } - - 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 rpcGetSerialButtonConfig() (CustomButtonSettings, error) { return getSerialSettings() } -func rpcSetSerialButtonConfig(config CustomButtonSettings) error { - return setSerialSettings(config) +func rpcSetSerialSettings(settings SerialSettings) error { + return setSerialSettings(settings) } const SerialCommandHistoryPath = "/userdata/serialCommandHistory.json" @@ -968,6 +875,30 @@ func rpcSetSerialCommandHistory(commandHistory []string) error { 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) + } + + return nil +} + +func rpcSetTerminalPaused(terminalPaused bool) error { + setTerminalPaused(terminalPaused) + return nil +} + func rpcGetUsbDevices() (usbgadget.Devices, error) { return *config.UsbDevices, nil } @@ -1243,94 +1174,94 @@ 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}, - "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"}}, - "getSerialButtonConfig": {Func: rpcGetSerialButtonConfig}, - "setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}}, - "getSerialCommandHistory": {Func: rpcGetSerialCommandHistory}, - "setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}}, - "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}, + "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"}}, } diff --git a/serial.go b/serial.go index 0d32d9c64..02301822f 100644 --- a/serial.go +++ b/serial.go @@ -2,10 +2,8 @@ package kvm import ( "bufio" - "encoding/base64" "encoding/json" "fmt" - "io" "os" "strconv" "strings" @@ -19,7 +17,7 @@ const serialPortPath = "/dev/ttyS3" var port serial.Port var serialMux *SerialMux -var consoleBr *ConsoleBroker +var consoleBroker *ConsoleBroker func mountATXControl() error { _ = port.SetMode(defaultMode) @@ -267,69 +265,15 @@ func unmountSerialButtons() error { return nil } -// ---- Serial Buttons RX fan-out (JSON-RPC events) ---- -var serialButtonsRXStopCh chan struct{} - -func startSerialButtonsRxLoop(session *Session) { - scopedLogger := serialLogger.With().Str("service", "custom_buttons_rx").Logger() - scopedLogger.Debug().Msg("Attempting to start RX reader.") - // Stop previous loop if running - if serialButtonsRXStopCh != nil { - stopSerialButtonsRxLoop() - } - serialButtonsRXStopCh = make(chan struct{}) - - go func() { - buf := make([]byte, 4096) - scopedLogger.Debug().Msg("Starting loop") - - for { - select { - case <-serialButtonsRXStopCh: - return - default: - if currentSession == nil { - time.Sleep(500 * time.Millisecond) - continue - } - n, err := port.Read(buf) - if err != nil { - if err != io.EOF { - scopedLogger.Debug().Err(err).Msg("serial RX read error") - } - time.Sleep(50 * time.Millisecond) - continue - } - if n == 0 { - continue - } - // Safe for any bytes: wrap in Base64 - b64 := base64.StdEncoding.EncodeToString(buf[:n]) - writeJSONRPCEvent("serial.rx", map[string]any{ - "base64": b64, - }, currentSession) - } - } - }() -} - -func stopSerialButtonsRxLoop() { - scopedLogger := serialLogger.With().Str("service", "custom_buttons_rx").Logger() - scopedLogger.Debug().Msg("Stopping RX reader.") - if serialButtonsRXStopCh != nil { - close(serialButtonsRXStopCh) - serialButtonsRXStopCh = nil - } -} - 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(command)) - if err != nil { - scopedLogger.Warn().Err(err).Str("Command", command).Msg("Failed to send serial command") - return err + 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 } @@ -340,15 +284,19 @@ var defaultMode = &serial.Mode{ StopBits: serial.OneStopBit, } -var SerialConfig = CustomButtonSettings{ +var serialPortMode = defaultMode + +var serialConfig = SerialSettings{ BaudRate: defaultMode.BaudRate, DataBits: defaultMode.DataBits, Parity: "none", StopBits: "1", - Terminator: Terminator{Label: "CR (\\r)", Value: "\r"}, - LineMode: true, + Terminator: Terminator{Label: "LF (\\n)", Value: "\n"}, HideSerialSettings: false, EnableEcho: false, + NormalizeMode: "names", + NormalizeLineEnd: "keep", + PreserveANSI: true, Buttons: []QuickButton{}, } @@ -368,62 +316,137 @@ type QuickButton struct { } // Mode describes a serial port configuration. -type CustomButtonSettings struct { +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 - LineMode bool `json:"lineMode"` // Whether to send each line when Enter is pressed, or each character immediately 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" + PreserveANSI bool `json:"preserveANSI"` // Whether to preserve ANSI escape codes Buttons []QuickButton `json:"buttons"` // Custom quick buttons } -func getSerialSettings() (CustomButtonSettings, error) { +func getSerialSettings() (SerialSettings, error) { switch defaultMode.StopBits { case serial.OneStopBit: - SerialConfig.StopBits = "1" + serialConfig.StopBits = "1" case serial.OnePointFiveStopBits: - SerialConfig.StopBits = "1.5" + serialConfig.StopBits = "1.5" case serial.TwoStopBits: - SerialConfig.StopBits = "2" + serialConfig.StopBits = "2" } switch defaultMode.Parity { case serial.NoParity: - SerialConfig.Parity = "none" + serialConfig.Parity = "none" case serial.OddParity: - SerialConfig.Parity = "odd" + serialConfig.Parity = "odd" case serial.EvenParity: - SerialConfig.Parity = "even" + serialConfig.Parity = "even" case serial.MarkParity: - SerialConfig.Parity = "mark" + serialConfig.Parity = "mark" case serial.SpaceParity: - SerialConfig.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 SerialConfig, err + return serialConfig, err } defer file.Close() // load and merge the default config with the user config - var loadedConfig CustomButtonSettings + 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 + 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 } - SerialConfig = loadedConfig // Update global config + 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: "", PreserveANSI: serialConfig.PreserveANSI, + } + consoleBroker.SetNormOptions(norm) + } return loadedConfig, nil } -func setSerialSettings(newSettings CustomButtonSettings) error { +func setSerialSettings(newSettings SerialSettings) error { logger.Trace().Str("path", serialSettingsPath).Msg("Saving config") file, err := os.Create(serialSettingsPath) @@ -474,15 +497,56 @@ func setSerialSettings(newSettings CustomButtonSettings) error { _ = port.SetMode(serialPortMode) - SerialConfig = newSettings // Update global config + serialConfig = newSettings // Update global config if serialMux != nil { - serialMux.SetEchoEnabled(SerialConfig.EnableEcho) + 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: "", PreserveANSI: serialConfig.PreserveANSI, + } + consoleBroker.SetNormOptions(norm) } return nil } +func setTerminalPaused(paused bool) { + if consoleBroker != nil { + consoleBroker.SetTerminalPaused(paused) + } +} + func initSerialPort() { _ = reopenSerialPort() switch config.ActiveExtension { @@ -510,80 +574,61 @@ func reopenSerialPort() error { // new broker (no sink yet—set it in handleSerialChannel.OnOpen) norm := NormOptions{ - Mode: ModeCaret, CRLF: CRLF_CRLF, TabRender: "", PreserveANSI: true, + Mode: ModeNames, CRLF: CRLF_LF, TabRender: "", PreserveANSI: true, } - if consoleBr != nil { - consoleBr.Close() + if consoleBroker != nil { + consoleBroker.Close() } - consoleBr = NewConsoleBroker(nil, norm) - consoleBr.Start() + consoleBroker = NewConsoleBroker(nil, norm) + consoleBroker.Start() // new mux if serialMux != nil { serialMux.Close() } - serialMux = NewSerialMux(port, consoleBr) - serialMux.SetEchoEnabled(SerialConfig.EnableEcho) // honor your setting + serialMux = NewSerialMux(port, consoleBroker) + serialMux.SetEchoEnabled(serialConfig.EnableEcho) // honor your setting serialMux.Start() - serialMux.SetEchoEnabled(SerialConfig.EnableEcho) return nil } -func handleSerialChannel(d *webrtc.DataChannel) { +func handleSerialChannel(dataChannel *webrtc.DataChannel) { scopedLogger := serialLogger.With(). - 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 - // } - // } - // }() + Uint16("data_channel_id", *dataChannel.ID()).Str("service", "serial terminal channel").Logger() + + dataChannel.OnOpen(func() { + // Plug the terminal sink into the broker - if consoleBr != nil { - consoleBr.SetSink(dataChannelSink{d: d}) - _ = d.SendText("RX: [serial attached]\r\n") + 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 { - // return - // } - // _, err := port.Write(append(msg.Data, []byte(SerialConfig.Terminator.Value)...)) - // if err != nil { - // scopedLogger.Warn().Err(err).Msg("Failed to write to serial") - // } + 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 } - 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) + 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 consoleBr != nil { - consoleBr.SetSink(nil) + if consoleBroker != nil { + consoleBroker.SetSink(nil) } }) } diff --git a/serial_console_helpers.go b/serial_console_helpers.go index aded0f8d0..4d6e2bb99 100644 --- a/serial_console_helpers.go +++ b/serial_console_helpers.go @@ -17,9 +17,9 @@ type Sink interface { SendText(s string) error } -type dataChannelSink struct{ d *webrtc.DataChannel } +type dataChannelSink struct{ dataChannel *webrtc.DataChannel } -func (s dataChannelSink) SendText(str string) error { return s.d.SendText(str) } +func (sink dataChannelSink) SendText(str string) error { return sink.dataChannel.SendText(str) } /* ---------- NORMALIZATION (applies to RX & TX) ---------- */ @@ -35,9 +35,10 @@ type CRLFMode int const ( CRLFAsIs CRLFMode = iota - CRLF_CRLF CRLF_LF CRLF_CR + CRLF_CRLF + CRLF_LFCR ) type NormOptions struct { @@ -93,28 +94,36 @@ func normalize(in []byte, opt NormOptions) string { case CRLFAsIs: out.WriteByte(b) i++ - case CRLF_CRLF: + case CRLF_LF: 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: + 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('\n') - case CRLF_CR: + out.WriteByte('\r') + case CRLF_CRLF: if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) { + out.WriteString("\n") i += 2 } else { + out.WriteString("\n") + i++ + } + case CRLF_LFCR: + if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) { + out.WriteString("\r") + i += 2 + } else { + out.WriteString("\r") i++ } - out.WriteByte('\r') } continue } @@ -184,11 +193,22 @@ type ConsoleBroker struct { 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 - pendingTX *consoleEvent - quietTimer *time.Timer - quietAfter time.Duration + rxAtLineEnd bool + txLineActive bool // true if we’re mid-line (prefix already written) + pendingTX *consoleEvent + quietTimer *time.Timer + quietAfter time.Duration // normalization norm NormOptions @@ -200,42 +220,78 @@ type ConsoleBroker struct { 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", + 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) 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 NormOptions) { 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 + was := b.terminalPaused + b.terminalPaused = v + if was && !v { + // we just unpaused: flush buffered output in order + scopedLogger.Info().Msg("Terminal unpaused; flushing buffered output") + b.flushBuffer() + } else if !was && v { + scopedLogger.Info().Msg("Terminal paused; buffering output") + } + case ev := <-b.in: switch ev.kind { case evRX: + scopedLogger.Info().Msg("Processing RX data from serial port") b.handleRX(ev.data) case evTX: + scopedLogger.Info().Msg("Processing TX echo request") b.handleTX(ev.data) } + case <-b.quietCh(): if b.pendingTX != nil { - _ = b.sink.SendText("\r\n") + b.emitToTerminal(b.lineSep()) // use CRLF policy b.flushPendingTX() b.rxAtLineEnd = true + b.txLineActive = false } } } @@ -268,12 +324,14 @@ func (b *ConsoleBroker) stopQuietTimer() { } 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 } text := normalize(data, b.norm) if text != "" { - _ = b.sink.SendText(fmt.Sprintf("%s: %s", b.labelRX, text)) + scopedLogger.Info().Msg("Emitting RX data to sink") + b.emitToTerminal(fmt.Sprintf("%s: %s", b.labelRX, text)) } last := data[len(data)-1] @@ -286,23 +344,46 @@ func (b *ConsoleBroker) handleRX(data []byte) { } 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 { - _ = b.sink.SendText("\r\n") + scopedLogger.Info().Msg("Emitting TX data to sink immediately") b.emitTX(data) - b.rxAtLineEnd = true return } + scopedLogger.Info().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 != "" { - _ = b.sink.SendText(fmt.Sprintf("%s: %s\r\n", b.labelTX, text)) + if text == "" { + return + } + + // Check if we’re in the middle of a TX line + if !b.txLineActive { + // Start new TX line with prefix + scopedLogger.Info().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.Info().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 } } @@ -312,6 +393,57 @@ func (b *ConsoleBroker) flushPendingTX() { } b.emitTX(b.pendingTX.data) b.pendingTX = nil + b.txLineActive = false +} + +func (b *ConsoleBroker) lineSep() string { + switch b.norm.CRLF { + case CRLF_CRLF: + return "\r\n" + case CRLF_CR: + return "\r" + case CRLF_LF: + return "\n" + default: + return "\n" + } +} + +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) ---------- */ @@ -351,10 +483,12 @@ 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.Info().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 { @@ -370,6 +504,7 @@ func (m *SerialMux) reader() { continue } if n > 0 && m.broker != nil { + scopedLogger.Info().Msg("Sending RX data to console broker") m.broker.Enqueue(consoleEvent{kind: evRX, data: append([]byte(nil), buf[:n]...)}) } } @@ -377,17 +512,20 @@ func (m *SerialMux) reader() { } func (m *SerialMux) writer() { + scopedLogger := serialLogger.With().Str("service", "SerialMux writer").Logger() for { select { case <-m.done: return case f := <-m.txQ: + scopedLogger.Info().Msg("Writing TX data to serial port") if _, err := m.port.Write(f.payload); err != nil { - serialLogger.Warn().Err(err).Str("src", f.source).Msg("serial write failed") + 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.Info().Msg("Sending TX echo to console broker") m.broker.Enqueue(consoleEvent{kind: evTX, data: append([]byte(nil), f.payload...)}) } } diff --git a/ui/src/components/CommandInput.tsx b/ui/src/components/CommandInput.tsx index 69f3e4d1a..526642500 100644 --- a/ui/src/components/CommandInput.tsx +++ b/ui/src/components/CommandInput.tsx @@ -13,6 +13,20 @@ function useCommandHistory(max = 300) { const { send } = useJsonRpc(); const [items, setItems] = useState([]); + 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) { @@ -90,7 +104,7 @@ function useCommandHistory(max = 300) { .reverse(); // newest first }, [items]); - return { push, up, down, resetTraversal, search }; + return { push, up, down, resetTraversal, search, deleteHistory }; } function Portal({ children }: { children: React.ReactNode }) { @@ -102,7 +116,7 @@ function Portal({ children }: { children: React.ReactNode }) { // ---------- reverse search popup ---------- function ReverseSearch({ - open, results, sel, setSel, onPick, onClose, + open, results, sel, setSel, onPick, onClose, onDeleteHistory }: { open: boolean; results: Hit[]; @@ -110,6 +124,7 @@ function ReverseSearch({ setSel: (i: number) => void; onPick: (val: string) => void; onClose: () => void; + onDeleteHistory: () => void; }) { const listRef = React.useRef(null); @@ -151,7 +166,10 @@ function ReverseSearch({
↑/↓ select • Enter accept • Esc close - +
+ + +
@@ -177,7 +195,7 @@ export function CommandInput({ const [revOpen, setRevOpen] = useState(false); const [revQuery, setRevQuery] = useState(""); const [sel, setSel] = useState(0); - const { push, up, down, resetTraversal, search } = useCommandHistory(); + const { push, up, down, resetTraversal, search, deleteHistory } = useCommandHistory(); const results = useMemo(() => search(revQuery), [revQuery, search]); @@ -280,6 +298,7 @@ export function CommandInput({ setSel={setSel} onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }} onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}} + onDeleteHistory={deleteHistory} /> )} diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index 7c0909956..8a2345c8b 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import "react-simple-keyboard/build/css/index.css"; -import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import { useEffect, useMemo, useCallback } from "react"; +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"; @@ -9,8 +9,10 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { cx } from "@/cva.config"; -import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores"; +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 "./Button"; @@ -67,20 +69,16 @@ function Terminal({ readonly dataChannel: RTCDataChannel; readonly type: AvailableTerminalTypes; }) { - const { terminalLineMode, terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); + 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]); - useEffect(() => { - if (!instance) return; - instance.options.disableStdin = !terminalLineMode; - instance.options.cursorStyle = terminalLineMode ? "bar" : "block"; - }, [instance, terminalLineMode]); - useEffect(() => { setTimeout(() => { setDisableVideoFocusTrap(isTerminalTypeEnabled); @@ -92,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; @@ -101,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") { @@ -118,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 @@ -141,7 +161,7 @@ function Terminal({ onDataHandler.dispose(); onKeyHandler.dispose(); }; - }, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType]); + }, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType, terminator]); useEffect(() => { if (!instance) return; @@ -172,8 +192,8 @@ function Terminal({ const sendLine = useCallback((line: string) => { // Just send; line ending/echo/normalization handled in serial.go - dataChannel.send(line); - }, [dataChannel]); + dataChannel.send(line + terminator); + }, [dataChannel, terminator]); return (
+ {terminalType == "serial" && ( +
-
- - {/* Serial settings (collapsible) */} - {!buttonConfig.hideSerialSettings && ( - <> -
- handleSerialButtonConfigChange("baudRate", Number(e.target.value))} - /> - - handleSerialButtonConfigChange("dataBits", Number(e.target.value))} - /> - - handleSerialButtonConfigChange("stopBits", e.target.value)} - /> - - handleSerialButtonConfigChange("parity", e.target.value)} - /> -
- handleSerialButtonConfigChange("terminator", {label: e.target.selectedOptions[0].text, value: e.target.value})} - /> -
- When sent, the selected line ending ({buttonConfig.terminator.label}) will be appended. -
-
-
- { - handleSerialButtonConfigChange("lineMode", e.target.value === "line") - setTerminalLineMode(e.target.value === "line"); - }} - /> -
- {buttonConfig.lineMode - ? "In Line Mode, input is sent when you press Enter in the input field." - : "In Raw Mode, input is sent immediately as you type in the console."} -
-
-
-
- - { - handleSerialButtonConfigChange("enableEcho", e.target.checked); - }} - /> - -
-
- - )} - - {/* Buttons grid */} -
- {sortedButtons.map((btn) => ( -
-
-
-
- ))} - {sortedButtons.length === 0 && ( -
No buttons yet. Click “Add Button”.
- )} -
- - {/* Editor drawer/modal (inline lightweight) */} - {editorOpen && ( -
-
- -
{editorOpen.id ? "Edit Button" : "New Button"}
-
-
-
- { - setDraftLabel(e.target.value); - }} - /> -
-
- { - setDraftCmd(e.target.value); - }} - /> - {draftTerminator.value != "" && ( -
- When sent, the selected line ending ({draftTerminator.label}) will be appended. -
- )} -
-
-
- setDraftTerminator({label: e.target.selectedOptions[0].text, value: e.target.value})} - /> -
-
-
-
-
-
- {editorOpen.id && ( - <> -
-
- )} -
- - - ); -} - -/** ============== 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)); -} - diff --git a/ui/src/components/extensions/SerialConsole.tsx b/ui/src/components/extensions/SerialConsole.tsx index e36365ff8..da641e981 100644 --- a/ui/src/components/extensions/SerialConsole.tsx +++ b/ui/src/components/extensions/SerialConsole.tsx @@ -1,131 +1,520 @@ -import { LuTerminal } from "react-icons/lu"; -import { useEffect, useState } from "react"; +import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCircleX, LuTerminal } from "react-icons/lu"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "@components/Button"; import Card from "@components/Card"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; -import { useUiStore } from "@/hooks/stores"; 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"; + + + +/** ============== 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: string; - dataBits: string; + 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 + preserveANSI: boolean; // future use + buttons: QuickButton[]; } +/** ============== Component ============== */ + export function SerialConsole() { + const { setTerminalType } = useUiStore(); + const { setTerminator } = useTerminalStore(); + const { send } = useJsonRpc(); - const [settings, setSettings] = useState({ - baudRate: "9600", - dataBits: "8", + + // extension config (buttons + prefs) + const [buttonConfig, setButtonConfig] = useState({ + baudRate: 9600, + dataBits: 8, stopBits: "1", parity: "none", + terminator: {label: "LF (\\n)", value: "\n"}, + hideSerialSettings: false, + enableEcho: false, + normalizeMode: "names", + normalizeLineEnd: "keep", + preserveANSI: true, + buttons: [], }); + type NormalizeMode = "caret" | "names" | "hex"; // note: caret (not carret) + + const normalizeHelp: Record = { + caret: "Caret notation: e.g. Ctrl+A as ^A, Esc as ^[", + names: "Names: e.g. Ctrl+A as , Esc as ", + hex: "Hex notation: e.g. Ctrl+A as 0x01, Esc as 0x1B", + }; + + // editor modal state + const [editorOpen, setEditorOpen] = useState(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( - `Failed to get serial settings: ${resp.error.data || "Unknown error"}`, + `Failed to get button config: ${resp.error.data || "Unknown error"}`, ); return; } - setSettings(resp.result as SerialSettings); + + setButtonConfig(resp.result as SerialSettings); + setTerminator((resp.result as SerialSettings).terminator.value); }); - }, [send]); - const handleSettingChange = (setting: keyof SerialSettings, value: string) => { - const newSettings = { ...settings, [setting]: value }; - send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => { + }, [send, setTerminator]); + + const handleSerialSettingsChange = (config: keyof SerialSettings, value: unknown) => { + const newButtonConfig = { ...buttonConfig, [config]: value }; + send("setSerialSettings", { settings: newButtonConfig }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update serial settings: ${resp.error.data || "Unknown error"}`); + return; + } + }); + setButtonConfig(newButtonConfig); + }; + + const onClickButton = (btn: QuickButton) => { + + const command = btn.command + btn.terminator.value; + + send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( - `Failed to update serial settings: ${resp.error.data || "Unknown error"}`, + `Failed to send custom command: ${resp.error.data || "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 = buttonConfig.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 = [...buttonConfig.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 = [...buttonConfig.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 + ? buttonConfig.buttons.map(b => (b.id === currentID ? { ...b, label, command , terminator} : b)) + : [...buttonConfig.buttons, { id: genId(), label, command, terminator, sort: buttonConfig.buttons.length }]; + + handleSerialSettingsChange("buttons", stableSort(nextButtons) ); + setEditorOpen(null); + }; + + /** simple reordering: alphabetical by sort, then label */ + const sortedButtons = useMemo(() => buttonConfig.buttons, [buttonConfig.buttons]); return (
- {/* Open Console Button */} -
+ {/* Top actions */} +
+

- {/* Settings */} -
- handleSettingChange("baudRate", e.target.value)} - /> - handleSettingChange("dataBits", e.target.value)} - /> + {/* Serial settings (collapsible) */} + {!buttonConfig.hideSerialSettings && ( + <> +
+ handleSerialSettingsChange("baudRate", Number(e.target.value))} + /> - handleSettingChange("stopBits", e.target.value)} - /> + handleSerialSettingsChange("dataBits", Number(e.target.value))} + /> - handleSettingChange("parity", e.target.value)} - /> + handleSerialSettingsChange("stopBits", e.target.value)} + /> + + handleSerialSettingsChange("parity", e.target.value)} + /> +
+ { + handleSerialSettingsChange("terminator", {label: e.target.selectedOptions[0].text, value: e.target.value}) + setTerminator(e.target.value); + }} + /> +
+ When sent, the selected line ending ({buttonConfig.terminator.label}) will be appended. +
+
+
+ { + handleSerialSettingsChange("normalizeMode", e.target.value) + }} + /> +
+ {normalizeHelp[(buttonConfig.normalizeMode as NormalizeMode)]} +
+
+
+ { + handleSerialSettingsChange("normalizeLineEnd", e.target.value) + }} + /> +
+
+ { + handleSerialSettingsChange("preserveANSI", e.target.value === "keep") + }} + /> +
+
+
+ + { + handleSerialSettingsChange("enableEcho", e.target.checked); + }} + /> + +
+
+ + )} + + {/* Buttons grid */} +
+ {sortedButtons.map((btn) => ( +
+
+
+
+ ))} + {sortedButtons.length === 0 && ( +
No buttons yet. Click “Add Button”.
+ )}
+ + {/* Editor drawer/modal (inline lightweight) */} + {editorOpen && ( +
+
+ +
{editorOpen.id ? "Edit Button" : "New Button"}
+
+
+
+ { + setDraftLabel(e.target.value); + }} + /> +
+
+ { + setDraftCmd(e.target.value); + }} + /> + {draftTerminator.value != "" && ( +
+ When sent, the selected line ending ({draftTerminator.label}) will be appended. +
+ )} +
+
+
+ setDraftTerminator({label: e.target.selectedOptions[0].text, value: e.target.value})} + /> +
+
+
+
+
+
+ {editorOpen.id && ( + <> +
+
+ )}
); } + +/** ============== 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)); +} + diff --git a/ui/src/components/popovers/ExtensionPopover.tsx b/ui/src/components/popovers/ExtensionPopover.tsx index dc57502d4..f36c05033 100644 --- a/ui/src/components/popovers/ExtensionPopover.tsx +++ b/ui/src/components/popovers/ExtensionPopover.tsx @@ -7,7 +7,6 @@ import { SettingsPageHeader } from "@components/SettingsPageheader"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { SerialConsole } from "@components/extensions/SerialConsole"; -import { SerialButtons } from "@components/extensions/SerialButtons"; import { Button } from "@components/Button"; import notifications from "@/notifications"; @@ -37,12 +36,6 @@ const AVAILABLE_EXTENSIONS: Extension[] = [ description: "Access your serial console extension", icon: LuTerminal, }, - { - id: "serial-buttons", - name: "Serial Buttons", - description: "Send custom serial signals by buttons", - icon: LuTerminal, - }, ]; export default function ExtensionPopover() { @@ -83,8 +76,6 @@ export default function ExtensionPopover() { return ; case "serial-console": return ; - case "serial-buttons": - return ; default: return null; } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 1a6349784..22f77043a 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -69,9 +69,6 @@ export interface UIState { terminalType: AvailableTerminalTypes; setTerminalType: (type: UIState["terminalType"]) => void; - - terminalLineMode: boolean; - setTerminalLineMode: (enabled: boolean) => void; } export const useUiStore = create(set => ({ @@ -99,9 +96,6 @@ export const useUiStore = create(set => ({ isAttachedVirtualKeyboardVisible: true, setAttachedVirtualKeyboardVisibility: (enabled: boolean) => set({ isAttachedVirtualKeyboardVisible: enabled }), - - terminalLineMode: true, - setTerminalLineMode: (enabled: boolean) => set({ terminalLineMode: enabled }), })); export interface RTCState { @@ -664,6 +658,18 @@ export const useDeviceStore = create(set => ({ setSystemVersion: (version: string) => set({ systemVersion: version }), })); +export interface TerminalState { + terminator: string | null; + + setTerminator: (version: string) => void; +} + +export const useTerminalStore = create(set => ({ + terminator: null, + + setTerminator: (version: string) => set({ terminator: version }), +})); + export interface DhcpLease { ip?: string; netmask?: string; From 2ce5623712419ad7f1887e520ab371ecca517fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20M=C3=BCller?= Date: Thu, 9 Oct 2025 08:26:50 +0200 Subject: [PATCH 7/8] Improve normalization --- serial.go | 6 ++- serial_console_helpers.go | 49 ++++++++++++++----- .../components/extensions/SerialConsole.tsx | 35 ++++++++++++- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/serial.go b/serial.go index 02301822f..5d35147f3 100644 --- a/serial.go +++ b/serial.go @@ -326,7 +326,9 @@ type SerialSettings struct { 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 } @@ -438,7 +440,7 @@ func getSerialSettings() (SerialSettings, error) { if consoleBroker != nil { norm := NormOptions{ - Mode: normalizeMode, CRLF: crlfMode, TabRender: "", PreserveANSI: serialConfig.PreserveANSI, + Mode: normalizeMode, CRLF: crlfMode, TabRender: serialConfig.TabRender, PreserveANSI: serialConfig.PreserveANSI, ShowNLTag: serialConfig.ShowNLTag, } consoleBroker.SetNormOptions(norm) } @@ -533,7 +535,7 @@ func setSerialSettings(newSettings SerialSettings) error { if consoleBroker != nil { norm := NormOptions{ - Mode: normalizeMode, CRLF: crlfMode, TabRender: "", PreserveANSI: serialConfig.PreserveANSI, + Mode: normalizeMode, CRLF: crlfMode, TabRender: serialConfig.TabRender, PreserveANSI: serialConfig.PreserveANSI, ShowNLTag: serialConfig.ShowNLTag, } consoleBroker.SetNormOptions(norm) } diff --git a/serial_console_helpers.go b/serial_console_helpers.go index 4d6e2bb99..849d080d5 100644 --- a/serial_console_helpers.go +++ b/serial_console_helpers.go @@ -46,6 +46,7 @@ type NormOptions struct { CRLF CRLFMode TabRender string // e.g. " " or "" to keep '\t' PreserveANSI bool + ShowNLTag bool // <- NEW: also print a visible tag for CR/LF } func normalize(in []byte, opt NormOptions) string { @@ -88,42 +89,68 @@ func normalize(in []byte, opt NormOptions) string { } } - // CR/LF normalization + // 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("") + } else { // saw LFCR + out.WriteString("") + } + } else { + if b == '\r' { + out.WriteString("") + } else { + out.WriteString("") + } + } + } + + // now emit the actual newline(s) per the normalization mode switch opt.CRLF { case CRLFAsIs: - out.WriteByte(b) - i++ + if isPair { + out.WriteByte(b) + out.WriteByte(in[i+1]) + i += 2 + } else { + out.WriteByte(b) + i++ + } case CRLF_LF: - if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) { + if isPair { 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')) { + if isPair { i += 2 } else { i++ } out.WriteByte('\r') case CRLF_CRLF: - if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) { - out.WriteString("\n") + if isPair { i += 2 } else { - out.WriteString("\n") i++ } + out.WriteString("\r\n") // (fixed to actually write CRLF) case CRLF_LFCR: - if i+1 < len(in) && ((b == '\r' && in[i+1] == '\n') || (b == '\n' && in[i+1] == '\r')) { - out.WriteString("\r") + if isPair { i += 2 } else { - out.WriteString("\r") i++ } + out.WriteString("\n\r") } continue } diff --git a/ui/src/components/extensions/SerialConsole.tsx b/ui/src/components/extensions/SerialConsole.tsx index da641e981..88f53bc97 100644 --- a/ui/src/components/extensions/SerialConsole.tsx +++ b/ui/src/components/extensions/SerialConsole.tsx @@ -33,7 +33,9 @@ interface SerialSettings { 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[]; } @@ -56,7 +58,9 @@ export function SerialConsole() { enableEcho: false, normalizeMode: "names", normalizeLineEnd: "keep", + tabRender: "", preserveANSI: true, + showNLTag: true, buttons: [], }); @@ -247,7 +251,7 @@ export function SerialConsole() { {/* Serial settings (collapsible) */} {!buttonConfig.hideSerialSettings && ( <> -
+
+
+ tag", value: "hide" }, + { label: "Show tag", value: "show" }, + ]} + value={buttonConfig.showNLTag ? "show" : "hide"} + onChange={(e) => { + handleSerialSettingsChange("showNLTag", e.target.value === "show") + }} + /> +
+
+ { + handleSerialSettingsChange("tabRender", e.target.value) + }} + /> +
+ Empty for no replacement +
+
Date: Thu, 9 Oct 2025 11:50:22 +0200 Subject: [PATCH 8/8] Minor serial helper improvements --- serial_console_helpers.go | 106 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/serial_console_helpers.go b/serial_console_helpers.go index 849d080d5..bb6f79a91 100644 --- a/serial_console_helpers.go +++ b/serial_console_helpers.go @@ -355,14 +355,45 @@ func (b *ConsoleBroker) handleRX(data []byte) { 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 != "" { - scopedLogger.Info().Msg("Emitting RX data to sink") - b.emitToTerminal(fmt.Sprintf("%s: %s", b.labelRX, text)) + if text == "" { + return } - last := data[len(data)-1] - b.rxAtLineEnd = (last == '\r' || last == '\n') + scopedLogger.Info().Msg("Emitting RX data to sink (with per-line prefixes)") + + // Prefix every line, regardless of how the EOLs look + lines := splitAfterAnyEOL(text, b.norm.CRLF) + + // 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.CRLF) + } + + // Persist state for next RX chunk + b.rxAtLineEnd = atLineEnd if b.pendingTX != nil && b.rxAtLineEnd { b.flushPendingTX() @@ -427,6 +458,8 @@ func (b *ConsoleBroker) lineSep() string { switch b.norm.CRLF { case CRLF_CRLF: return "\r\n" + case CRLF_LFCR: + return "\n\r" case CRLF_CR: return "\r" case CRLF_LF: @@ -436,6 +469,69 @@ func (b *ConsoleBroker) lineSep() string { } } +// splitAfterAnyEOL splits text into lines keeping the EOL with each piece. +// For CRLFAsIs it treats \r, \n, \r\n, and \n\r as EOLs. +// For other modes it uses the normalized separator. +func splitAfterAnyEOL(text string, mode CRLFMode) []string { + if text == "" { + return nil + } + + // Fast path for normalized modes + switch mode { + case CRLF_LF: + return strings.SplitAfter(text, "\n") + case CRLF_CR: + return strings.SplitAfter(text, "\r") + case CRLF_CRLF: + return strings.SplitAfter(text, "\r\n") + case CRLF_LFCR: + return strings.SplitAfter(text, "\n\r") + } + + // CRLFAsIs: 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 CRLFMode) bool { + if s == "" { + return false + } + switch mode { + case CRLF_CRLF: + return strings.HasSuffix(s, "\r\n") + case CRLF_LFCR: + return strings.HasSuffix(s, "\n\r") + case CRLF_LF: + return strings.HasSuffix(s, "\n") + case CRLF_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