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; }