mirror of https://github.com/jetkvm/kvm.git
Add order buttons and response field
This commit is contained in:
parent
d8f670fcba
commit
67e9136b03
|
@ -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")
|
||||||
|
|
55
serial.go
55
serial.go
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue