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/4] 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 82b12d04..552779ae 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 00000000..a4f01898 --- /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 f36c0503..dc57502d 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/4] Add backend to send custom commands --- jsonrpc.go | 14 ++++++ serial.go | 27 +++++++++++ .../components/extensions/SerialButtons.tsx | 47 ++++++++++--------- 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index e448f952..a55a1c71 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 5439d135..85165f9b 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 a4f01898..aa47428d 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 t = buttonConfig.terminator ?? ""; - return raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t; - }; + 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; + + 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/4] 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 a55a1c71..6e4276d2 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 85165f9b..9d77e5bc 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 aa47428d..c67a8eae 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 ba3e667c..75c57407 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 c67a8eae..d5c2d01b 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; -} - 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); }); - }); + }, [send, setTerminalLineMode]); - 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 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; - 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 }]; + // 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 } : 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 bfbbb26e..1a634978 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"