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] 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 )} />