Compare commits

...

7 Commits

Author SHA1 Message Date
Sevi 0cabb62cee
Merge cfd5e7cfab into 359778967f 2025-09-26 08:52:12 +02:00
Aveline 359778967f
chore: remove msgpack from gin dependency (#833) 2025-09-26 08:52:07 +02:00
Sevi cfd5e7cfab
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons 2025-09-24 22:21:06 +02:00
Severin Müller 67e9136b03 Add order buttons and response field 2025-09-23 22:11:54 +02:00
Severin Müller d8f670fcba Add backend to send custom commands 2025-09-23 16:49:45 +02:00
Sevi b2b3ee40a7
Merge branch 'jetkvm:dev' into dev 2025-09-22 17:11:23 +02:00
Severin Müller b8d4464904 Create new extension "Serial Buttons" 2025-09-22 17:10:58 +02:00
5 changed files with 656 additions and 1 deletions

View File

@ -8,7 +8,7 @@ VERSION := 0.4.8
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm KVM_PKG_NAME := github.com/jetkvm/kvm
GO_BUILD_ARGS := -tags netgo -tags timetzdata GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \ GO_LDFLAGS := \
-s -w \ -s -w \

View File

@ -758,6 +758,8 @@ func rpcSetActiveExtension(extensionId string) error {
_ = unmountATXControl() _ = unmountATXControl()
case "dc-power": case "dc-power":
_ = unmountDCControl() _ = unmountDCControl()
case "serial-buttons":
_ = unmountSerialButtons()
} }
config.ActiveExtension = extensionId config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
@ -768,6 +770,8 @@ func rpcSetActiveExtension(extensionId string) error {
_ = mountATXControl() _ = mountATXControl()
case "dc-power": case "dc-power":
_ = mountDCControl() _ = mountDCControl()
case "serial-buttons":
_ = mountSerialButtons()
} }
return nil return nil
} }
@ -802,6 +806,15 @@ func rpcGetATXState() (ATXState, error) {
return state, nil return state, nil
} }
func rpcSendCustomCommand(command string) error {
logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command")
err := sendCustomCommand(command)
if err != nil {
return fmt.Errorf("failed to send custom command in jsonrpc: %w", err)
}
return nil
}
type SerialSettings struct { type SerialSettings struct {
BaudRate string `json:"baudRate"` BaudRate string `json:"baudRate"`
DataBits string `json:"dataBits"` DataBits string `json:"dataBits"`
@ -893,6 +906,64 @@ func rpcSetSerialSettings(settings SerialSettings) error {
return nil 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`
HideSerialResponse bool `json:"hideSerialResponse"` // lowercase `bool`
}
func rpcGetSerialButtonConfig() (SerialButtonConfig, error) {
config := SerialButtonConfig{
Buttons: []QuickButton{},
Terminator: "\r",
HideSerialSettings: false,
HideSerialResponse: true,
}
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) { func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil return *config.UsbDevices, nil
} }
@ -1240,8 +1311,11 @@ var rpcHandlers = map[string]RPCHandler{
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState}, "getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}},
"getSerialSettings": {Func: rpcGetSerialSettings}, "getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getSerialButtonConfig": {Func: rpcGetSerialButtonConfig},
"setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}},
"getUsbDevices": {Func: rpcGetUsbDevices}, "getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},

View File

@ -2,6 +2,7 @@ package kvm
import ( import (
"bufio" "bufio"
"encoding/base64"
"io" "io"
"strconv" "strconv"
"strings" "strings"
@ -251,6 +252,83 @@ func setDCRestoreState(state int) error {
return nil return nil
} }
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_tx").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{ var defaultMode = &serial.Mode{
BaudRate: 115200, BaudRate: 115200,
DataBits: 8, DataBits: 8,

View File

@ -0,0 +1,494 @@
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";
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";
import { TextAreaWithLabel } from "@components/TextArea";
/** ============== 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;
hideSerialResponse: boolean;
}
/** ============== Component ============== */
export function SerialButtons() {
// 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<SerialSettings>({
baudRate: "9600",
dataBits: "8",
stopBits: "1",
parity: "none",
});
// extension config (buttons + prefs)
const [buttonConfig, setButtonConfig] = useState<ButtonConfig>({
buttons: [],
terminator: "",
hideSerialSettings: false,
hideSerialResponse: true,
});
// editor modal state
const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null);
const [draftLabel, setDraftLabel] = useState("");
const [draftCmd, setDraftCmd] = useState("");
const [serialResponse, setSerialResponse] = useState("");
const [paused, setPaused] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null);
// 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;
}
setButtonConfig(resp.result as ButtonConfig);
});
});
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);
});
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;
send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
});
};
/** 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 = (id: string) => {
const nextButtons = buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) ;
handleSerialButtonConfigChange("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 }));
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();
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 (
<div className="space-y-4">
<SettingsPageHeader
title="Serial Buttons"
description="Quick custom commands over the extension serial port"
/>
<Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Top actions */}
<div className="flex flex-wrap justify-around items-center gap-3">
<Button
size="SM"
theme="primary"
LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff}
text={buttonConfig.hideSerialSettings ? "Show Settings" : "Hide Settings"}
onClick={() => handleSerialButtonConfigChange("hideSerialSettings", !buttonConfig.hideSerialSettings )}
/>
<Button
size="SM"
theme="primary"
LeadingIcon={LuPlus}
text="Add Button"
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>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Serial settings (collapsible) */}
{!buttonConfig.hideSerialSettings && (
<>
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
{ label: "4800", value: "4800" },
{ label: "9600", value: "9600" },
{ label: "19200", value: "19200" },
{ label: "38400", value: "38400" },
{ label: "57600", value: "57600" },
{ label: "115200", value: "115200" },
]}
value={serialSettings.baudRate}
onChange={(e) => handleSerialSettingChange("baudRate", e.target.value)}
/>
<SelectMenuBasic
label="Data Bits"
options={[
{ label: "8", value: "8" },
{ label: "7", value: "7" },
]}
value={serialSettings.dataBits}
onChange={(e) => handleSerialSettingChange("dataBits", e.target.value)}
/>
<SelectMenuBasic
label="Stop Bits"
options={[
{ label: "1", value: "1" },
{ label: "1.5", value: "1.5" },
{ label: "2", value: "2" },
]}
value={serialSettings.stopBits}
onChange={(e) => handleSerialSettingChange("stopBits", e.target.value)}
/>
<SelectMenuBasic
label="Parity"
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },
{ label: "Odd", value: "odd" },
]}
value={serialSettings.parity}
onChange={(e) => handleSerialSettingChange("parity", e.target.value)}
/>
</div>
<SelectMenuBasic
label="Line ending"
options={[
{ label: "None", value: "" },
{ label: "CR (\\r)", value: "\r" },
{ label: "CRLF (\\r\\n)", value: "\r\n" },
]}
value={buttonConfig.terminator}
onChange={(e) => handleSerialButtonConfigChange("terminator", e.target.value)}
/>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
</>
)}
{/* Buttons grid */}
<div className="grid grid-cols-2 gap-2 pt-2">
{sortedButtons.map((btn) => (
<div key={btn.id} className="flex items-stretch gap-2 min-w-0">
<div className=" flex-1 min-w-0 ">
<Button
size="MD"
fullWidth
className="overflow-hidden text-ellipsis whitespace-nowrap"
theme="primary"
text={btn.label}
onClick={() => onClickButton(btn)}
/>
</div>
<Button
size="MD"
theme="light"
className="shrink-0"
LeadingIcon={LuPencil}
onClick={() => editBtn(btn)}
aria-label={`Edit ${btn.label}`}
/>
</div>
))}
{sortedButtons.length === 0 && (
<div className="col-span-2 text-sm text-black dark:text-slate-300">No buttons yet. Click Add Button.</div>
)}
</div>
{/* Editor drawer/modal (inline lightweight) */}
{editorOpen && (
<div className="mt-4 border rounded-md p-3 bg-slate-50 dark:bg-slate-900/30">
<div className="flex items-center gap-2 mb-2">
<LuSettings2 className="h-3.5 text-white shrink-0 justify-start" />
<div className="font-medium text-black dark:text-white">{editorOpen.id ? "Edit Button" : "New Button"}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<InputFieldWithLabel
size="SM"
type="text"
label="Label"
placeholder="New Command"
value={draftLabel}
onChange={e => {
setDraftLabel(e.target.value);
}}
/>
</div>
<div>
<InputFieldWithLabel
size="SM"
type="text"
label="Command"
placeholder="Command to send"
value={draftCmd}
onChange={e => {
setDraftCmd(e.target.value);
}}
/>
{buttonConfig.terminator != "" && (
<div className="text-xs text-white opacity-70 mt-1">
The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent.
</div>
)}
</div>
</div>
<div className="flex gap-2 mt-3">
<Button size="SM" theme="primary" LeadingIcon={LuSave} text="Save" onClick={saveDraft} />
<Button size="SM" theme="primary" text="Cancel" onClick={() => setEditorOpen(null)} />
{editorOpen.id && (
<>
<Button
size="SM"
theme="danger"
LeadingIcon={LuTrash2}
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>
)}
{/* 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>
</Card>
</div>
);
}
/** ============== 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));
}

View File

@ -7,6 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole"; import { SerialConsole } from "@components/extensions/SerialConsole";
import { SerialButtons } from "@components/extensions/SerialButtons";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import notifications from "@/notifications"; import notifications from "@/notifications";
@ -36,6 +37,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
description: "Access your serial console extension", description: "Access your serial console extension",
icon: LuTerminal, icon: LuTerminal,
}, },
{
id: "serial-buttons",
name: "Serial Buttons",
description: "Send custom serial signals by buttons",
icon: LuTerminal,
},
]; ];
export default function ExtensionPopover() { export default function ExtensionPopover() {
@ -76,6 +83,8 @@ export default function ExtensionPopover() {
return <DCPowerControl />; return <DCPowerControl />;
case "serial-console": case "serial-console":
return <SerialConsole />; return <SerialConsole />;
case "serial-buttons":
return <SerialButtons />;
default: default:
return null; return null;
} }