From 9b624f5d9abc7b98439979aad312bb27598453eb Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sun, 27 Nov 2022 19:13:46 +0100 Subject: [PATCH] Serial Selection and detection (#312) --- gui/package.json | 2 +- gui/src/App.tsx | 44 +- gui/src/components/MainLayout.tsx | 4 +- gui/src/components/SerialDetectionModal.tsx | 199 +++++ gui/src/components/commons/ArrowLink.tsx | 5 +- gui/src/components/commons/BaseModal.tsx | 28 + gui/src/components/commons/Button.tsx | 10 +- gui/src/components/commons/Dropdown.tsx | 112 +++ gui/src/components/commons/Input.tsx | 23 +- gui/src/components/commons/NumberSelector.tsx | 4 +- gui/src/components/commons/icon/UsbIcon.tsx | 7 + .../onboarding/pages/ConnectTracker.tsx | 63 +- .../components/onboarding/pages/EnterVR.tsx | 4 - .../components/onboarding/pages/WifiCreds.tsx | 47 +- .../autobone-steps/Preparation.tsx | 2 +- .../autobone-steps/StartRecording.tsx | 2 +- .../autobone-steps/VerifyResults.tsx | 3 +- .../settings/pages/GeneralSettings.tsx | 78 +- gui/src/components/settings/pages/Serial.tsx | 104 ++- gui/src/hooks/config.ts | 15 +- gui/src/hooks/previous.ts | 9 + gui/src/hooks/wifi-form.tsx | 61 ++ gui/vite.config.ts | 3 +- .../dev/slimevr/protocol/ProtocolAPI.java | 3 + .../java/dev/slimevr/protocol/RPCHandler.java | 844 ------------------ .../{ => datafeed}/DataFeedBuilder.java | 2 +- .../{ => datafeed}/DataFeedHandler.java | 15 +- .../protocol/{ => pubsub}/HashedTopicId.java | 2 +- .../protocol/{ => pubsub}/PubSubHandler.java | 5 +- .../protocol/{ => rpc}/RPCBuilder.java | 2 +- .../dev/slimevr/protocol/rpc/RPCHandler.java | 364 ++++++++ .../protocol/rpc/serial/RPCSerialHandler.java | 233 +++++ .../rpc/settings/RPCSettingsBuilder.java | 110 +++ .../rpc/settings/RPCSettingsHandler.java | 214 +++++ .../dev/slimevr/serial/SerialHandler.java | 44 +- solarxr-protocol | 2 +- 36 files changed, 1642 insertions(+), 1027 deletions(-) create mode 100644 gui/src/components/SerialDetectionModal.tsx create mode 100644 gui/src/components/commons/BaseModal.tsx create mode 100644 gui/src/components/commons/Dropdown.tsx create mode 100644 gui/src/components/commons/icon/UsbIcon.tsx create mode 100644 gui/src/hooks/previous.ts create mode 100644 gui/src/hooks/wifi-form.tsx delete mode 100644 server/src/main/java/dev/slimevr/protocol/RPCHandler.java rename server/src/main/java/dev/slimevr/protocol/{ => datafeed}/DataFeedBuilder.java (99%) rename server/src/main/java/dev/slimevr/protocol/{ => datafeed}/DataFeedHandler.java (93%) rename server/src/main/java/dev/slimevr/protocol/{ => pubsub}/HashedTopicId.java (96%) rename server/src/main/java/dev/slimevr/protocol/{ => pubsub}/PubSubHandler.java (97%) rename server/src/main/java/dev/slimevr/protocol/{ => rpc}/RPCBuilder.java (96%) create mode 100644 server/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.java create mode 100644 server/src/main/java/dev/slimevr/protocol/rpc/serial/RPCSerialHandler.java create mode 100644 server/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsBuilder.java create mode 100644 server/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.java diff --git a/gui/package.json b/gui/package.json index 0dfb7786c..a07b3da74 100644 --- a/gui/package.json +++ b/gui/package.json @@ -33,7 +33,7 @@ "typescript": "^4.6.3" }, "scripts": { - "start": "vite", + "start": "vite --force", "build": "vite build", "dev": "tauri dev", "tauri": "tauri", diff --git a/gui/src/App.tsx b/gui/src/App.tsx index dded39f68..8c291ff96 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,47 +1,49 @@ -import { - useProvideWebsocketApi, - WebSocketApiContext, -} from './hooks/websocket-api'; +import { useEffect } from 'react'; import { BrowserRouter as Router, - Routes, - Route, Outlet, + Route, + Routes } from 'react-router-dom'; import { Home } from './components/home/Home'; -import { AppContextProvider } from './components/providers/AppContext'; -import { useEffect } from 'react'; import { MainLayoutRoute } from './components/MainLayout'; -import { SettingsLayoutRoute } from './components/settings/SettingsLayout'; +import { AppContextProvider } from './components/providers/AppContext'; import { GeneralSettings } from './components/settings/pages/GeneralSettings'; import { Serial } from './components/settings/pages/Serial'; +import { SettingsLayoutRoute } from './components/settings/SettingsLayout'; +import { + useProvideWebsocketApi, + WebSocketApiContext +} from './hooks/websocket-api'; import { Event, listen } from '@tauri-apps/api/event'; -import { TopBar } from './components/TopBar'; -import { ConfigContextProvider } from './components/providers/ConfigContext'; -import { OnboardingLayout } from './components/onboarding/OnboardingLayout'; -import { HomePage } from './components/onboarding/pages/Home'; -import { WifiCredsPage } from './components/onboarding/pages/WifiCreds'; -import { ConnectTrackersPage } from './components/onboarding/pages/ConnectTracker'; import { OnboardingContextProvider } from './components/onboarding/OnboardingContextProvicer'; -import { TrackersAssignPage } from './components/onboarding/pages/trackers-assign/TrackerAssignment'; +import { OnboardingLayout } from './components/onboarding/OnboardingLayout'; +import { AutomaticProportionsPage } from './components/onboarding/pages/body-proportions/AutomaticProportions'; +import { ManualProportionsPage } from './components/onboarding/pages/body-proportions/ManualProportions'; +import { ConnectTrackersPage } from './components/onboarding/pages/ConnectTracker'; +import { DonePage } from './components/onboarding/pages/Done'; import { EnterVRPage } from './components/onboarding/pages/EnterVR'; +import { HomePage } from './components/onboarding/pages/Home'; import { AutomaticMountingPage } from './components/onboarding/pages/mounting/AutomaticMounting'; import { ManualMountingPage } from './components/onboarding/pages/mounting/ManualMounting'; import { ResetTutorialPage } from './components/onboarding/pages/ResetTutorial'; -import { AutomaticProportionsPage } from './components/onboarding/pages/body-proportions/AutomaticProportions'; -import { ManualProportionsPage } from './components/onboarding/pages/body-proportions/ManualProportions'; -import { TrackerSettingsPage } from './components/tracker/TrackerSettings'; -import { DonePage } from './components/onboarding/pages/Done'; +import { TrackersAssignPage } from './components/onboarding/pages/trackers-assign/TrackerAssignment'; +import { WifiCredsPage } from './components/onboarding/pages/WifiCreds'; +import { ConfigContextProvider } from './components/providers/ConfigContext'; +import { SerialDetectionModal } from './components/SerialDetectionModal'; import { OSCSettings } from './components/settings/pages/OSCSettings'; +import { TopBar } from './components/TopBar'; +import { TrackerSettingsPage } from './components/tracker/TrackerSettings'; import { useConfig } from './hooks/config'; function Layout() { const { loading } = useConfig(); - if (loading) return (<>); + if (loading) return <>; return ( <> + (items: T[], getKey: (item: T) => string) => { + const map: any = {}; + for (const item of items) { + const key = getKey(item); + map[key] = item; + } + return map; +}; + +const detectChanges = ( + prevItems: T[], + nextItems: T[], + getKey: (item: T) => string, + compareItems: (a: T, b: T) => boolean +) => { + const mappedItems = mapItems(prevItems, getKey); + const addedItems = []; + const updatedItems = []; + const removedItems = []; + const unchangedItems = []; + for (const nextItem of nextItems) { + const itemKey = getKey(nextItem); + if (itemKey in mappedItems) { + const prevItem = mappedItems[itemKey]; + if (delete mappedItems[itemKey] && compareItems(prevItem, nextItem)) { + unchangedItems.push(nextItem); + } else { + updatedItems.push(nextItem); + } + } else { + addedItems.push(nextItem); + } + } + for (const itemKey in mappedItems) { + if (itemKey in mappedItems) { + removedItems.push(mappedItems[itemKey]); + } + } + return { addedItems, updatedItems, removedItems, unchangedItems }; +}; + +export function SerialDetectionModal() { + const { config } = useConfig(); + const nav = useNavigate(); + const { pathname } = useLocation(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [currentDevices, setCurrentDevices] = useState( + null + ); + const prevDevices = usePrevious(currentDevices); + const [isOpen, setOpen] = useState(null); + const [showWifiForm, setShowWifiForm] = useState(false); + + const { WifiForm, handleSubmit, submitWifiCreds, formState, hasWifiCreds } = + useWifiForm(); + + useEffect(() => { + if (prevDevices == null) return; + + const changes = detectChanges( + prevDevices || [], + currentDevices || [], + (item) => item.port?.toString() || 'error', + (a, b) => a.port == b.port && a.name == b.name + ); + if (changes.addedItems.length === 1) { + setOpen(changes.addedItems[0]); + } + }, [prevDevices, currentDevices]); + + useEffect(() => { + let timerId: NodeJS.Timer; + + if ( + config?.watchNewDevices && + !['/settings/serial', '/onboarding/connect-trackers'].includes(pathname) + ) { + timerId = setInterval(() => { + sendRPCPacket( + RpcMessage.SerialDevicesRequest, + new SerialDevicesRequestT() + ); + }, 3000); + } + return () => { + clearInterval(timerId); + }; + }, [config, sendRPCPacket, pathname]); + + const openSerial = () => { + nav('/settings/serial', { state: { serialPort: isOpen?.port } }); + setOpen(null); + }; + + const openWifi = () => { + if (!hasWifiCreds) { + setShowWifiForm(true); + } else { + setOpen(null); + nav('/onboarding/connect-trackers', { state: { alonePage: true } }); + } + }; + + const modalWifiSubmit = (form: WifiFormData) => { + submitWifiCreds(form); + setOpen(null); + nav('/onboarding/connect-trackers', { state: { alonePage: true } }); + }; + + useRPCPacket( + RpcMessage.SerialDevicesResponse, + (val: SerialDevicesResponseT) => { + setCurrentDevices(val.devices); + } + ); + + return ( + setOpen(null)}> +
+ {!showWifiForm && ( + <> +
+ +
+ + New serial device detected! + + + {isOpen?.name || 'unknown'} + + + Please select what you want to do with it + +
+
+ + + + + + )} + {showWifiForm && ( +
+
+ + + New serial device detected! + + + Enter your wifi credentials! + +
+
+ +
+ + + +
+ )} +
+
+ ); +} diff --git a/gui/src/components/commons/ArrowLink.tsx b/gui/src/components/commons/ArrowLink.tsx index 645435aa2..b2744e9d2 100644 --- a/gui/src/components/commons/ArrowLink.tsx +++ b/gui/src/components/commons/ArrowLink.tsx @@ -12,7 +12,7 @@ export function ArrowLink({ to: string; children: ReactChild; direction?: 'left' | 'right'; - variant?: 'flat' | 'boxed'; + variant?: 'flat' | 'boxed' | 'boxed-2'; }) { const classes = useMemo(() => { const variantsMap = { @@ -20,6 +20,9 @@ export function ArrowLink({ boxed: classNames( 'justify-between bg-background-70 rounded-md hover:bg-background-60 p-3' ), + 'boxed-2': classNames( + 'justify-between bg-background-60 rounded-md hover:bg-background-50 p-3' + ), }; return classNames( variantsMap[variant], diff --git a/gui/src/components/commons/BaseModal.tsx b/gui/src/components/commons/BaseModal.tsx new file mode 100644 index 000000000..238b9b99c --- /dev/null +++ b/gui/src/components/commons/BaseModal.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import { ReactNode } from 'react'; +import ReactModal from 'react-modal'; + +export function BaseModal({ + children, + ...props +}: { + isOpen: boolean; + children: ReactNode; +} & ReactModal.Props) { + return ( + + {children} + + ); +} diff --git a/gui/src/components/commons/Button.tsx b/gui/src/components/commons/Button.tsx index 428316b02..4a0f202ad 100644 --- a/gui/src/components/commons/Button.tsx +++ b/gui/src/components/commons/Button.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import React, { ReactChild, useMemo } from 'react'; +import React, { ReactChild, ReactNode, useMemo } from 'react'; import { NavLink } from 'react-router-dom'; import { LoaderIcon } from './icon/LoaderIcon'; @@ -47,9 +47,9 @@ export function Button({ rounded = false, ...props }: { - children: ReactChild; - icon?: ReactChild; - variant: 'primary' | 'secondary' | 'tierciary' | 'quaternary'; + children: ReactNode; + icon?: ReactNode; + variant: 'primary' | 'secondary' | 'tiertiary' | 'quaternary'; to?: string; loading?: boolean; rounded?: boolean; @@ -69,7 +69,7 @@ export function Button({ 'bg-background-60 hover:bg-background-60 cursor-not-allowed text-background-40': disabled, }), - tierciary: classNames({ + tiertiary: classNames({ 'bg-background-50 hover:bg-background-40 text-standard text-background-10': !disabled, 'bg-background-50 hover:bg-background-50 cursor-not-allowed text-background-40': diff --git a/gui/src/components/commons/Dropdown.tsx b/gui/src/components/commons/Dropdown.tsx new file mode 100644 index 000000000..566b3f2b7 --- /dev/null +++ b/gui/src/components/commons/Dropdown.tsx @@ -0,0 +1,112 @@ +import classNames from 'classnames'; +import { useState } from 'react'; +import { Control, Controller } from 'react-hook-form'; + +export interface DropdownItem { + label: string; + value: string; +} + +export function Dropdown({ + direction = 'up', + variant = 'primary', + placeholder, + control, + name, + items = [], +}: { + direction?: 'up' | 'down'; + variant?: 'primary' | 'secondary'; + placeholder: string; + control: Control; + name: string; + items: DropdownItem[]; +}) { + const [isOpen, setOpen] = useState(false); + + return ( + ( +
+ {isOpen && ( +
setOpen(false)} + >
+ )} +
+
setOpen((open) => !open)} + > +
+ {items.find((i) => i.value == value)?.label || placeholder} +
+
+ +
+
+ {isOpen && ( +
+
    + {items.map((item) => ( +
  • { + onChange(item.value); + setOpen(false); + }} + key={item.value} + > + {item.label} +
  • + ))} +
+
+ )} +
+
+ )} + /> + ); +} diff --git a/gui/src/components/commons/Input.tsx b/gui/src/components/commons/Input.tsx index 639ecc2a0..2f855e984 100644 --- a/gui/src/components/commons/Input.tsx +++ b/gui/src/components/commons/Input.tsx @@ -3,7 +3,8 @@ import { forwardRef, HTMLInputTypeAttribute, MouseEvent, - useState, + useMemo, + useState } from 'react'; import { EyeIcon } from './icon/EyeIcon'; @@ -12,10 +13,11 @@ export interface InputProps { placeholder?: string; label?: string; autocomplete?: boolean; + variant?: 'primary' | 'secondary'; } export const Input = forwardRef(function AppInput( - { type, placeholder, label, autocomplete, ...props }, + { type, placeholder, label, autocomplete, variant = 'primary', ...props }, ref ) { const [forceText, setForceText] = useState(false); @@ -25,6 +27,18 @@ export const Input = forwardRef(function AppInput( setForceText(!forceText); }; + const classes = useMemo(() => { + const variantsMap = { + primary: classNames('bg-background-60 border-background-60'), + secondary: classNames('bg-background-50 border-background-50'), + }; + + return classNames( + variantsMap[variant], + 'w-full focus:ring-transparent focus:ring-offset-transparent focus:outline-transparent rounded-md bg-background-60 border-background-60 focus:border-accent-background-40 placeholder:text-background-30 text-standard relative' + ); + }, [variant]); + return (