Add order buttons and response field

This commit is contained in:
Severin Müller 2025-09-23 22:11:54 +02:00
parent d8f670fcba
commit 67e9136b03
3 changed files with 212 additions and 19 deletions

View File

@ -807,10 +807,10 @@ func rpcGetATXState() (ATXState, error) {
} }
func rpcSendCustomCommand(command string) 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) err := sendCustomCommand(command)
if err != nil { 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 return nil
} }
@ -917,6 +917,7 @@ type SerialButtonConfig struct {
Buttons []QuickButton `json:"buttons"` // slice of QuickButton Buttons []QuickButton `json:"buttons"` // slice of QuickButton
Terminator string `json:"terminator"` // CR/CRLF/None Terminator string `json:"terminator"` // CR/CRLF/None
HideSerialSettings bool `json:"hideSerialSettings"` // lowercase `bool` HideSerialSettings bool `json:"hideSerialSettings"` // lowercase `bool`
HideSerialResponse bool `json:"hideSerialResponse"` // lowercase `bool`
} }
func rpcGetSerialButtonConfig() (SerialButtonConfig, error) { func rpcGetSerialButtonConfig() (SerialButtonConfig, error) {
@ -924,6 +925,7 @@ func rpcGetSerialButtonConfig() (SerialButtonConfig, error) {
Buttons: []QuickButton{}, Buttons: []QuickButton{},
Terminator: "\r", Terminator: "\r",
HideSerialSettings: false, HideSerialSettings: false,
HideSerialResponse: true,
} }
file, err := os.Open("/userdata/serialButtons_config.json") file, err := os.Open("/userdata/serialButtons_config.json")

View File

@ -2,6 +2,7 @@ package kvm
import ( import (
"bufio" "bufio"
"encoding/base64"
"io" "io"
"strconv" "strconv"
"strings" "strings"
@ -253,17 +254,67 @@ func setDCRestoreState(state int) error {
func mountSerialButtons() error { func mountSerialButtons() error {
_ = port.SetMode(defaultMode) _ = port.SetMode(defaultMode)
startSerialButtonsRxLoop(currentSession)
return nil return nil
} }
func unmountSerialButtons() error { func unmountSerialButtons() error {
stopSerialButtonsRxLoop()
_ = reopenSerialPort() _ = reopenSerialPort()
return nil 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 { 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.") scopedLogger.Info().Str("Command", command).Msg("Sending custom command.")
_, err := port.Write([]byte("\n")) _, err := port.Write([]byte("\n"))
if err != nil { if err != nil {

View File

@ -1,5 +1,5 @@
import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave } from "react-icons/lu"; import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCirclePause, LuCirclePlay } from "react-icons/lu";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
@ -8,6 +8,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { InputFieldWithLabel } from "@components/InputField"; import { InputFieldWithLabel } from "@components/InputField";
import { TextAreaWithLabel } from "@components/TextArea";
/** ============== Types ============== */ /** ============== Types ============== */
@ -29,12 +30,42 @@ interface ButtonConfig {
buttons: QuickButton[]; buttons: QuickButton[];
terminator: string; // CR/CRLF/None terminator: string; // CR/CRLF/None
hideSerialSettings: boolean; hideSerialSettings: boolean;
hideSerialResponse: boolean;
} }
/** ============== Component ============== */ /** ============== Component ============== */
export function SerialButtons() { 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) // serial settings (same as SerialConsole)
const [serialSettings, setSerialSettings] = useState<SerialSettings>({ const [serialSettings, setSerialSettings] = useState<SerialSettings>({
@ -49,12 +80,16 @@ export function SerialButtons() {
buttons: [], buttons: [],
terminator: "", terminator: "",
hideSerialSettings: false, hideSerialSettings: false,
hideSerialResponse: true,
}); });
// editor modal state // editor modal state
const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null); const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null);
const [draftLabel, setDraftLabel] = useState(""); const [draftLabel, setDraftLabel] = useState("");
const [draftCmd, setDraftCmd] = useState(""); const [draftCmd, setDraftCmd] = useState("");
const [serialResponse, setSerialResponse] = useState("");
const [paused, setPaused] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null);
// load serial settings like SerialConsole // load serial settings like SerialConsole
useEffect(() => { useEffect(() => {
@ -101,8 +136,15 @@ export function SerialButtons() {
} }
setButtonConfig(newButtonConfig); 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) => { const onClickButton = (btn: QuickButton) => {
/** build final string to send: /** build final string to send:
@ -141,6 +183,48 @@ export function SerialButtons() {
setEditorOpen(null); 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 saveDraft = () => {
const label = draftLabel.trim() || "Unnamed"; const label = draftLabel.trim() || "Unnamed";
const command = draftCmd.trim(); const command = draftCmd.trim();
@ -176,7 +260,7 @@ export function SerialButtons() {
size="SM" size="SM"
theme="primary" theme="primary"
LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff} 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 )} onClick={() => handleSerialButtonConfigChange("hideSerialSettings", !buttonConfig.hideSerialSettings )}
/> />
<Button <Button
@ -186,12 +270,19 @@ export function SerialButtons() {
text="Add Button" text="Add Button"
onClick={addNew} onClick={addNew}
/> />
<Button
size="SM"
theme="primary"
LeadingIcon={buttonConfig.hideSerialResponse ? LuEye : LuEyeOff}
text={buttonConfig.hideSerialResponse ? "View RX" : "Hide RX"}
onClick={() => handleSerialButtonConfigChange("hideSerialResponse", !buttonConfig.hideSerialResponse )}
/>
</div> </div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Serial settings (collapsible) */} {/* Serial settings (collapsible) */}
{!buttonConfig.hideSerialSettings && ( {!buttonConfig.hideSerialSettings && (
<> <>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<SelectMenuBasic <SelectMenuBasic
label="Baud Rate" label="Baud Rate"
@ -244,7 +335,7 @@ export function SerialButtons() {
<SelectMenuBasic <SelectMenuBasic
label="Line ending" label="Line ending"
options={[ options={[
{ label: "None", value: "none" }, { label: "None", value: "" },
{ label: "CR (\\r)", value: "\r" }, { label: "CR (\\r)", value: "\r" },
{ label: "CRLF (\\r\\n)", value: "\r\n" }, { label: "CRLF (\\r\\n)", value: "\r\n" },
]} ]}
@ -315,25 +406,74 @@ export function SerialButtons() {
setDraftCmd(e.target.value); setDraftCmd(e.target.value);
}} }}
/> />
<div className="text-xs text-white opacity-70 mt-1"> {buttonConfig.terminator != "" && (
<div className="text-xs text-white opacity-70 mt-1">
The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent. The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent.
</div> </div>
)}
</div> </div>
</div> </div>
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<Button size="SM" theme="primary" LeadingIcon={LuSave} text="Save" onClick={saveDraft} /> <Button size="SM" theme="primary" LeadingIcon={LuSave} text="Save" onClick={saveDraft} />
<Button size="SM" theme="primary" text="Cancel" onClick={() => setEditorOpen(null)} /> <Button size="SM" theme="primary" text="Cancel" onClick={() => setEditorOpen(null)} />
{editorOpen.id &&(<Button {editorOpen.id && (
size="SM" <>
theme="danger" <Button
LeadingIcon={LuTrash2} size="SM"
text="Delete" theme="danger"
onClick={() => removeBtn(editorOpen.id!)} LeadingIcon={LuTrash2}
aria-label={`Delete ${draftLabel}`} text="Delete"
/>)} onClick={() => removeBtn(editorOpen.id!)}
aria-label={`Delete ${draftLabel}`}
/>
<Button
size="SM"
theme="primary"
LeadingIcon={LuArrowBigUp}
onClick={() => moveUpBtn(editorOpen.id!)}
/>
<Button
size="SM"
theme="primary"
LeadingIcon={LuArrowBigDown}
onClick={() => moveDownBtn(editorOpen.id!)}
/>
</>
)}
</div> </div>
</div> </div>
)} )}
{/* Serial response (collapsible) */}
{!buttonConfig.hideSerialResponse && (
<>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
<TextAreaWithLabel
ref={taRef}
readOnly
label="RX response from serial connection"
value={serialResponse|| ""}
rows={3}
onChange={e => setSerialResponse(e.target.value)}
placeholder="Will show the response recieved from the serial port."
/>
<div className="flex items-center gap-2">
<Button
size="XS"
theme="primary"
text={paused ? "Resume" : "Pause"}
LeadingIcon={paused ? LuCirclePlay : LuCirclePause}
onClick={() => setPaused(p => !p)}
/>
<Button
size="XS"
theme="primary"
text="Clear"
LeadingIcon={LuTrash2}
onClick={() => setSerialResponse("")}
/>
</div>
</>
)}
</div> </div>
</Card> </Card>
</div> </div>