From 77263e73f7b7d256edfdb0aee391f426b04b2a90 Mon Sep 17 00:00:00 2001 From: jackislanding Date: Thu, 27 Feb 2025 02:53:47 -0600 Subject: [PATCH] Feature/usb config - Rebasing USB Config Changes on Dev Branch (#185) * rebasing on dev branch * fixed formatting * fixed formatting * removed query params * moved usb settings to hardware setting * swapped from error to log * added fix for any change to product name now resulting in show the spinner as custom on page reload * formatting --------- Co-authored-by: JackTheRooster Co-authored-by: Adam Shiervani --- config.go | 16 ++ jsonrpc.go | 25 +++ ui/src/components/USBConfigDialog.tsx | 216 +++++++++++++++++++++++++ ui/src/components/sidebar/settings.tsx | 168 ++++++++++++++++++- ui/src/hooks/stores.ts | 24 +++ usb.go | 48 +++++- 6 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/USBConfigDialog.tsx diff --git a/config.go b/config.go index ccbce8e..4119a0c 100644 --- a/config.go +++ b/config.go @@ -12,6 +12,14 @@ type WakeOnLanDevice struct { MacAddress string `json:"macAddress"` } +type UsbConfig struct { + VendorId string `json:"vendor_id"` + ProductId string `json:"product_id"` + SerialNumber string `json:"serial_number"` + Manufacturer string `json:"manufacturer"` + Product string `json:"product"` +} + type Config struct { CloudURL string `json:"cloud_url"` CloudToken string `json:"cloud_token"` @@ -28,6 +36,7 @@ type Config struct { DisplayMaxBrightness int `json:"display_max_brightness"` DisplayDimAfterSec int `json:"display_dim_after_sec"` DisplayOffAfterSec int `json:"display_off_after_sec"` + UsbConfig UsbConfig `json:"usb_config"` } const configPath = "/userdata/kvm_config.json" @@ -39,6 +48,13 @@ var defaultConfig = &Config{ DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes + UsbConfig: UsbConfig{ + VendorId: "0x1d6b", //The Linux Foundation + ProductId: "0x0104", //Multifunction Composite Gadget + SerialNumber: "", + Manufacturer: "JetKVM", + Product: "JetKVM USB Emulation Device", + }, } var ( diff --git a/jsonrpc.go b/jsonrpc.go index 1de55b2..a07d461 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -538,6 +538,29 @@ func rpcSetUsbEmulationState(enabled bool) error { } } +func rpcGetUsbConfig() (UsbConfig, error) { + LoadConfig() + return config.UsbConfig, nil +} + +func rpcSetUsbConfig(usbConfig UsbConfig) error { + LoadConfig() + config.UsbConfig = usbConfig + + err := UpdateGadgetConfig() + if err != nil { + return fmt.Errorf("failed to write gadget config: %w", err) + } + + err = SaveConfig() + if err != nil { + return fmt.Errorf("failed to save usb config: %w", err) + } + + log.Printf("[jsonrpc.go:rpcSetUsbConfig] usb config set to %s", usbConfig) + return nil +} + func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) { if config.WakeOnLanDevices == nil { return []WakeOnLanDevice{}, nil @@ -791,6 +814,8 @@ var rpcHandlers = map[string]RPCHandler{ "isUpdatePending": {Func: rpcIsUpdatePending}, "getUsbEmulationState": {Func: rpcGetUsbEmulationState}, "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, + "getUsbConfig": {Func: rpcGetUsbConfig}, + "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, "getVirtualMediaState": {Func: rpcGetVirtualMediaState}, "getStorageSpace": {Func: rpcGetStorageSpace}, diff --git a/ui/src/components/USBConfigDialog.tsx b/ui/src/components/USBConfigDialog.tsx new file mode 100644 index 0000000..cb12447 --- /dev/null +++ b/ui/src/components/USBConfigDialog.tsx @@ -0,0 +1,216 @@ +import { GridCard } from "@/components/Card"; +import {useCallback, useEffect, useState} from "react"; +import { Button } from "@components/Button"; +import LogoBlueIcon from "@/assets/logo-blue.svg"; +import LogoWhiteIcon from "@/assets/logo-white.svg"; +import Modal from "@components/Modal"; +import { InputFieldWithLabel } from "./InputField"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { useUsbConfigModalStore } from "@/hooks/stores"; +import ExtLink from "@components/ExtLink"; +import { UsbConfigState } from "@/hooks/stores" + +export default function USBConfigDialog({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ); +} + +export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + const { modalView, setModalView } = useUsbConfigModalStore(); + const [error, setError] = useState(null); + + const [send] = useJsonRpc(); + + const handleUsbConfigChange = useCallback((usbConfig: object) => { + send("setUsbConfig", { usbConfig }, resp => { + if ("error" in resp) { + setError(`Failed to update USB Config: ${resp.error.data || "Unknown error"}`); + return; + } + setModalView("updateUsbConfigSuccess"); + }); + }, [send, setModalView]); + + return ( + +
+ {modalView === "updateUsbConfig" && ( + setOpen(false)} + error={error} + /> + )} + {modalView === "updateUsbConfigSuccess" && ( + setOpen(false)} + /> + )} +
+
+ ); +} + +function UpdateUsbConfigModal({ + onSetUsbConfig, + onCancel, + error, +}: { + onSetUsbConfig: (usb_config: object) => void; + onCancel: () => void; + error: string | null; +}) { + const [usbConfigState, setUsbConfigState] = useState({ + vendor_id: '', + product_id: '', + serial_number: '', + manufacturer: '', + product: '' + }); + const [send] = useJsonRpc(); + + const syncUsbConfig = useCallback(() => { + send("getUsbConfig", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB Config:", resp.error); + } else { + setUsbConfigState(resp.result as UsbConfigState); + } + }); + }, [send, setUsbConfigState]); + + // Load stored usb config from the backend + useEffect(() => { + syncUsbConfig(); + }, [syncUsbConfig]); + + const handleUsbVendorIdChange = (value: string) => { + setUsbConfigState({... usbConfigState, vendor_id: value}) + }; + + const handleUsbProductIdChange = (value: string) => { + setUsbConfigState({... usbConfigState, product_id: value}) + }; + + const handleUsbSerialChange = (value: string) => { + setUsbConfigState({... usbConfigState, serial_number: value}) + }; + + const handleUsbManufacturer = (value: string) => { + setUsbConfigState({... usbConfigState, manufacturer: value}) + }; + + const handleUsbProduct = (value: string) => { + setUsbConfigState({... usbConfigState, product: value}) + }; + + return ( +
+
+ + +
+
+
+

USB Emulation Configuration

+

+ Set custom USB parameters to control how the USB device is emulated. + The device will rebind once the parameters are updated. +

+
+ + Look up USB Device IDs here + +
+
+ handleUsbVendorIdChange(e.target.value)} + /> + handleUsbProductIdChange(e.target.value)} + /> + handleUsbSerialChange(e.target.value)} + /> + handleUsbManufacturer(e.target.value)} + /> + handleUsbProduct(e.target.value)} + /> +
+
+ {error &&

{error}

} +
+
+ ); +} + +function SuccessModal({ + headline, + description, + onClose, +}: { + headline: string; + description: string; + onClose: () => void; +}) { + return ( +
+
+ + +
+
+
+

{headline}

+

{description}

+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index 340fe80..fb19f89 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -5,6 +5,7 @@ import { useSettingsStore, useUiStore, useUpdateStore, + useUsbConfigModalStore } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -13,7 +14,7 @@ import { SectionHeader } from "@components/SectionHeader"; import { GridCard } from "@components/Card"; import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { cx } from "@/cva.config"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import { isOnDevice } from "@/main"; import PointingFinger from "@/assets/pointing-finger.svg"; import MouseIcon from "@/assets/mouse-icon.svg"; @@ -26,6 +27,8 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog"; import { LocalDevice } from "@routes/devices.$id"; import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; +import USBConfigDialog from "@components/USBConfigDialog"; +import { UsbConfigState } from "@/hooks/stores" import { CLOUD_APP, DEVICE_API } from "@/ui.config"; import { InputFieldWithLabel } from "../InputField"; @@ -52,6 +55,19 @@ export function SettingsItem({ ); } + +const generatedSerialNumber = [generateNumber(1,9), generateHex(7,7), 0, 1].join("&"); + +function generateNumber(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function generateHex(min: number, max: number) { + const len = generateNumber(min, max); + const n = (Math.random() * 0xfffff * 1000000).toString(16); + return n.slice(0, len); +} + const defaultEdid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; const edids = [ @@ -86,6 +102,7 @@ export default function SettingsSidebar() { const [jiggler, setJiggler] = useState(false); const [edid, setEdid] = useState(null); const [customEdidValue, setCustomEdidValue] = useState(null); + const [usbConfigProduct, setUsbConfigProduct] = useState(""); const [isAdopted, setAdopted] = useState(false); const [deviceId, setDeviceId] = useState(null); @@ -113,6 +130,86 @@ export default function SettingsSidebar() { }); }, [send]); + const usbConfigs = useMemo(() => [ + { + label: "JetKVM Default", + value: "JetKVM USB Emulation Device" + }, + { + label: "Logitech Universal Adapter", + value: "Logitech USB Input Device" + }, + { + label: "Microsoft Wireless MultiMedia Keyboard", + value: "Wireless MultiMedia Keyboard" + }, + { + label: "Dell Multimedia Pro Keyboard", + value: "Multimedia Pro Keyboard" + } + ], []); + + + interface USBConfig { + vendor_id: string; + product_id: string; + serial_number: string | null; + manufacturer: string; + product: string; + } + + type UsbConfigMap = Record; + + + const usbConfigData: UsbConfigMap = { + "JetKVM USB Emulation Device": { + vendor_id: "0x1d6b", + product_id: "0x0104", + serial_number: deviceId, + manufacturer: "JetKVM", + product: "JetKVM USB Emulation Device", + }, + "Logitech USB Input Device": { + vendor_id: "0x046d", + product_id: "0xc52b", + serial_number: generatedSerialNumber, + manufacturer: "Logitech (x64)", + product: "Logitech USB Input Device", + }, + "Wireless MultiMedia Keyboard": { + vendor_id: "0x045e", + product_id: "0x005f", + serial_number: generatedSerialNumber, + manufacturer: "Microsoft", + product: "Wireless MultiMedia Keyboard", + }, + "Multimedia Pro Keyboard": { + vendor_id: "0x413c", + product_id: "0x2011", + serial_number: generatedSerialNumber, + manufacturer: "Dell Inc.", + product: "Multimedia Pro Keyboard", + } + } + + const syncUsbConfigProduct = useCallback(() => { + send("getUsbConfig", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB Config:", resp.error); + } else { + console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result); + const usbConfigState = resp.result as UsbConfigState + const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) ? usbConfigState.product : "custom" + setUsbConfigProduct(product); + } + }); + }, [send, usbConfigs]); + + // Load stored usb config product from the backend + useEffect(() => { + syncUsbConfigProduct(); + }, [syncUsbConfigProduct]); + const handleUsbEmulationToggle = useCallback( (enabled: boolean) => { send("setUsbEmulationState", { enabled: enabled }, resp => { @@ -186,6 +283,21 @@ export default function SettingsSidebar() { }); }; + const handleUsbConfigChange = (product: string) => { + const usbConfig = usbConfigData[product]; + console.info(`USB config: ${JSON.stringify(usbConfig)}`) + send("setUsbConfig", { usbConfig }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set usb config: ${resp.error.data || "Unknown error"}`, + ); + return; + } + setUsbConfigProduct(usbConfig.product); + notifications.success(`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`); + }); + }; + const handleJigglerChange = (enabled: boolean) => { send("setJigglerState", { enabled }, resp => { if ("error" in resp) { @@ -430,7 +542,9 @@ export default function SettingsSidebar() { }, []); const { setModalView: setLocalAuthModalView } = useLocalAuthModalStore(); + const { setModalView: setUsbConfigModalView } = useUsbConfigModalStore(); const [isLocalAuthDialogOpen, setIsLocalAuthDialogOpen] = useState(false); + const [isUsbConfigDialogOpen, setIsUsbConfigDialogOpen] = useState(false); useEffect(() => { if (isOnDevice) getDevice(); @@ -444,6 +558,14 @@ export default function SettingsSidebar() { } }, [getDevice, isLocalAuthDialogOpen]); + useEffect(() => { + if (!isOnDevice) return; + // Refresh device status when the local usb config dialog is closed + if (!isUsbConfigDialogOpen) { + getDevice(); + } + }, [getDevice, isUsbConfigDialogOpen]); + const revalidator = useRevalidator(); const [currentTheme, setCurrentTheme] = useState(() => { @@ -954,6 +1076,41 @@ export default function SettingsSidebar() {

The display will wake up when the connection state changes, or when touched.

+ + { + if (e.target.value === "custom") { + setUsbConfigProduct(e.target.value); + } else { + handleUsbConfigChange(e.target.value as string); + } + }} + options={[...usbConfigs, { value: "custom", label: "Custom" }]} + /> + + {(usbConfigProduct === "custom") && ( + +