diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78519ac..228a445 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,6 @@ jobs: cache: "yarn" cache-dependency-path: react/yarn.lock - run: yarn - - run: npx prettier . --check - run: yarn build --if-present - run: yarn test + - run: yarn prettier . --check diff --git a/react/.prettierignore b/react/.prettierignore new file mode 100644 index 0000000..e665aa0 --- /dev/null +++ b/react/.prettierignore @@ -0,0 +1,4 @@ +# Ignore yarn.lock and other Yarn-specific files +yarn.lock +package.json +**/node_modules/* \ No newline at end of file diff --git a/react/.prettierrc b/react/.prettierrc new file mode 100644 index 0000000..a598fca --- /dev/null +++ b/react/.prettierrc @@ -0,0 +1,3 @@ +{ + "endOfLine": "lf" +} diff --git a/react/package.json b/react/package.json index caf5ab6..ff63405 100644 --- a/react/package.json +++ b/react/package.json @@ -11,6 +11,7 @@ "@types/node": "^16.7.13", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "date-fns": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-scripts": "5.0.1", @@ -18,7 +19,8 @@ "web-vitals": "^2.1.0" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.21.11" + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "prettier": "^3.3.3" }, "scripts": { "start": "react-scripts start", diff --git a/react/src/App.css b/react/src/App.css deleted file mode 100644 index 74b5e05..0000000 --- a/react/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/react/src/App.tsx b/react/src/App.tsx deleted file mode 100644 index 9cc63d9..0000000 --- a/react/src/App.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import logo from "./logo.svg"; -import "./App.css"; - -function App() { - return ( -
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
-
- ); -} - -export default App; diff --git a/react/src/components/App.tsx b/react/src/components/App.tsx new file mode 100644 index 0000000..2ba9545 --- /dev/null +++ b/react/src/components/App.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import "css/App.css"; +import Button from "components/Button"; +import { Status } from "types/status"; +import Console from "components/Console"; +import { WebSocketProvider } from "components/WebSocketContext"; + +function App() { + const [botStatus, setBotStatus] = useState(Status.Offline); + + function startBot() { + if (botStatus === Status.Ok) return; + if (botStatus === Status.Offline) { + setBotStatus(Status.Loading); + // set to Status.Ok in 5s + } + } + function stopBot() { + if (botStatus !== Status.Ok) return; + setBotStatus(Status.Offline); + } + + useEffect(() => { + if (botStatus === Status.Loading) { + const timeoutId = setTimeout(() => { + setBotStatus(Status.Ok); + }, 5000); + + return () => clearTimeout(timeoutId); // Cleanup function + } + }, [botStatus]); + + return ( +
+ +
+ ); +} + +export default App; diff --git a/react/src/components/Button.tsx b/react/src/components/Button.tsx new file mode 100644 index 0000000..3528ea4 --- /dev/null +++ b/react/src/components/Button.tsx @@ -0,0 +1,14 @@ +import { ButtonProps } from "types/button"; +import "css/Button.css"; +import StatusIndicator from "./StatusIndicator"; + +function Button(props: ButtonProps) { + return ( + + ); +} + +export default Button; diff --git a/react/src/components/Console.tsx b/react/src/components/Console.tsx new file mode 100644 index 0000000..7d69a8a --- /dev/null +++ b/react/src/components/Console.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState } from "react"; +import { format } from "date-fns"; +import { useWebSocket } from "./WebSocketContext"; +import "css/Console.css"; + +function Console() { + const consoleOutputRef = useRef(null); + const [socketHistory, setSocketHistory] = useState([]); + const [autoScroll, setAutoScroll] = useState(true); + const maxHistoryLength = 1000; // in lines + const { data } = useWebSocket({ + // All the topics we want to subscribe to. This should be pretty + // much every topic that exists on ros. Currently topic and topic2 + // for testing but it won't error if the topics don't exist. + includeTopics: ["/topic", "/topic2"], + }); + + // update history when receiving new data + useEffect(() => { + if (data === null) return; + + setSocketHistory((prev: string[]) => { + const time = format(new Date(), "HH:mm:ss.SSS"); + const topic = data.topic; + const body = data.msg.data; + const nextMsg = `[${time}] [${topic}]: ${body}`; + const newHistory = [...prev, nextMsg]; + if (newHistory.length > maxHistoryLength) { + newHistory.splice(0, 1); + } + return newHistory; + }); + }, [data]); + + // Console scroll stuff + useEffect(() => { + if (!consoleOutputRef.current) return; + + // Keeps the console scrolled at the bottom + if (autoScroll) { + const { current } = consoleOutputRef; + current.scrollTop = current.scrollHeight; + } + + // Scrolls up when console history is full and more lines get added to prevent it from shifting down + if (!autoScroll && socketHistory.length === maxHistoryLength) { + const { current } = consoleOutputRef; + current.scrollTop -= 14; // 14px represents roughly 1 line + } + }, [socketHistory, autoScroll]); + + function checkEnableAutoScroll() { + if (consoleOutputRef.current) { + const { current } = consoleOutputRef; + // this confused me, it's total height - amount scrolled - height of what's visible + let distFromBottom = current.scrollHeight - current.scrollTop; + distFromBottom -= current.clientHeight; + + // 10px to add a little bit of margin + if (distFromBottom < 10) { + // Re-enable auto scroll when the user scrolls to the bottom + setAutoScroll(true); + } else { + // Disable auto scroll when the user scrolls manually + setAutoScroll(false); + } + } + } + + function renderConsoleText() { + let text = socketHistory.join("\n"); + if (text === "") text = "Waiting for messages..."; + if (socketHistory.length === maxHistoryLength) { + text = `...history limited to ${maxHistoryLength} lines\n${text}`; + } + return text; + } + + return ( +
+

Console

+
+
{renderConsoleText()}
+
+
+ ); +} + +export default Console; diff --git a/react/src/components/StatusIndicator.tsx b/react/src/components/StatusIndicator.tsx new file mode 100644 index 0000000..7e9b4af --- /dev/null +++ b/react/src/components/StatusIndicator.tsx @@ -0,0 +1,7 @@ +import { Status } from "types/button"; + +function StatusIndicator(props: { status?: Status }) { + return <>{props.status && }; +} + +export default StatusIndicator; diff --git a/react/src/components/WebSocketContext.tsx b/react/src/components/WebSocketContext.tsx new file mode 100644 index 0000000..ab1f89a --- /dev/null +++ b/react/src/components/WebSocketContext.tsx @@ -0,0 +1,106 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { + RosDataType, + SocketResponse, + WebSocketContextType, +} from "types/websocket"; + +const WebSocketContext = createContext( + undefined, +); + +export function WebSocketProvider({ children }: { children: React.ReactNode }) { + const [socket, setSocket] = useState(null); + const listeners = useRef void>>(new Set()); + + useEffect(() => { + // if it already exists it already has all this stuff set + if (socket !== null) return; + + const toSubscribe = [ + { topic: "/topic", type: RosDataType.String }, + { topic: "/topic2", type: RosDataType.String }, + ]; + + const _socket = new WebSocket("ws://127.0.0.1:9090"); + _socket.onopen = () => { + console.log("Connected to ROS bridge"); + toSubscribe.forEach((item) => { + _socket.send(JSON.stringify({ op: "subscribe", ...item })); + }); + }; + + _socket.onmessage = (event) => { + const data: SocketResponse = JSON.parse(event.data); + listeners.current.forEach((listener) => listener(data)); + }; + + _socket.onerror = (error) => { + console.error("WebSocket error", error); + }; + + _socket.onclose = () => { + console.log("Socket closed"); + setSocket(null); + }; + + setSocket(_socket); + }, [socket]); + + function sendMessage(msg: object) { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(msg)); + } + } + + const addMessageListener = useCallback((callback: (data: any) => void) => { + listeners.current.add(callback); + return () => { + listeners.current.delete(callback); + }; + }, []); + + return ( + + {children} + + ); +} + +// Custom hook to use WebSocket data with filtering options +export function useWebSocket(filterOptions: { + includeTopics?: string[]; + excludeTopics?: string[]; +}) { + const context = useContext(WebSocketContext); + if (!context) { + throw new Error("useWebSocket must be used within a WebSocketProvider"); + } + + const { addMessageListener, sendMessage } = context; + const [data, setData] = useState(null); + + useEffect(() => { + function handleData(data: SocketResponse) { + const { includeTopics, excludeTopics } = filterOptions; + const topic = data.topic; + + if (includeTopics && !includeTopics.includes(topic)) return; + if (excludeTopics && excludeTopics.includes(topic)) return; + + setData(data); + } + + const removeListener = addMessageListener(handleData); + return removeListener; + }, [addMessageListener, filterOptions]); + + return { data, sendMessage }; +} diff --git a/react/src/css/App.css b/react/src/css/App.css new file mode 100644 index 0000000..0174b40 --- /dev/null +++ b/react/src/css/App.css @@ -0,0 +1,78 @@ +.App { + text-align: center; + min-height: 100vh; + /* display: flex; */ + /* flex-direction: row; */ + /* align-items: center; */ + /* justify-content: center; */ + font-size: calc(10px + 2vmin); +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.StatusOk { + display: inline-block; + margin-right: 0.25em; + width: 0.5em; + height: 0.5em; + border-radius: 1em; + background-color: limegreen; +} + +.StatusLoading { + display: inline-block; + margin-right: 0.25em; + width: 0.5em; + height: 0.5em; + border-radius: 1em; + background-color: magenta; +} + +.StatusWarning { + display: inline-block; + margin-right: 0.25em; + width: 0.5em; + height: 0.5em; + border-radius: 1em; + background-color: orange; +} + +.StatusError { + display: inline-block; + margin-right: 0.25em; + width: 0.5em; + height: 0.5em; + border-radius: 1em; + background-color: red; +} + +.StatusOffline { + display: inline-block; + margin-right: 0.25em; + width: 0.5em; + height: 0.5em; + border-radius: 1em; + background-color: lightgray; +} diff --git a/react/src/css/Button.css b/react/src/css/Button.css new file mode 100644 index 0000000..740284c --- /dev/null +++ b/react/src/css/Button.css @@ -0,0 +1,16 @@ +.Button { + /* display: inline; */ + padding: 4px; + margin: 2px; + border: 2px solid #a49665; + background-color: #005035; + color: white; +} + +.Button:hover { + background-color: #003d29; +} + +.Button:active { + background-color: #002719; +} diff --git a/react/src/css/Console.css b/react/src/css/Console.css new file mode 100644 index 0000000..d821f66 --- /dev/null +++ b/react/src/css/Console.css @@ -0,0 +1,25 @@ +.Console { + width: 100vw; + height: 60vh; + display: flex; + flex-direction: column; + justify-content: center; + h1 { + margin: 10px; + font-size: xx-large; + } +} + +.ConsoleBody { + align-self: center; + font-size: small; + background-color: #000000d8; + color: white; + text-align: left; + width: 80vw; + height: 60vh; + text-align: left; + overflow-y: scroll; + border-radius: 1em; + padding: 0.5em; +} diff --git a/react/src/index.tsx b/react/src/index.tsx index 4fa2a47..e23932a 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; -import App from "./App"; +import App from "./components/App"; import reportWebVitals from "./reportWebVitals"; const root = ReactDOM.createRoot( diff --git a/react/src/App.test.tsx b/react/src/test/App.test.tsx similarity index 55% rename from react/src/App.test.tsx rename to react/src/test/App.test.tsx index d76787e..dbb2640 100644 --- a/react/src/App.test.tsx +++ b/react/src/test/App.test.tsx @@ -1,9 +1,9 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import App from "./App"; +import App from "components/App"; -test("renders learn react link", () => { +test("renders start button", () => { render(); - const linkElement = screen.getByText(/learn react/i); + const linkElement = screen.getByText(/start/i); expect(linkElement).toBeInTheDocument(); }); diff --git a/react/src/types/button.ts b/react/src/types/button.ts new file mode 100644 index 0000000..2bb6ed3 --- /dev/null +++ b/react/src/types/button.ts @@ -0,0 +1,8 @@ +import { Status } from "./status"; + +export type ButtonProps = { + name: string; + onClick?: () => void; + status?: Status; +}; +export { Status }; diff --git a/react/src/types/status.ts b/react/src/types/status.ts new file mode 100644 index 0000000..9cd3585 --- /dev/null +++ b/react/src/types/status.ts @@ -0,0 +1,7 @@ +export enum Status { + Ok = "StatusOk", + Loading = "StatusLoading", + Warning = "StatusWarning", + Error = "StatusError", + Offline = "StatusOffline", +} diff --git a/react/src/types/websocket.ts b/react/src/types/websocket.ts new file mode 100644 index 0000000..3074ecf --- /dev/null +++ b/react/src/types/websocket.ts @@ -0,0 +1,24 @@ +export enum RosDataType { + String = "std_msgs/String", +} + +export type RosSubscriber = { + topic: string; + type: RosDataType | string; +}; + +export type WebSocketContextType = { + // object for now; temporary + sendMessage: (msg: object) => void; + addMessageListener: (callback: (data: any) => void) => () => void; +}; + +export type SocketResponse = { + msg: SocketResponseMsg; + topic: string; + op: string; +}; + +export type SocketResponseMsg = { + data: any; +}; diff --git a/react/tsconfig.json b/react/tsconfig.json index 810b7f6..75fbeaa 100644 --- a/react/tsconfig.json +++ b/react/tsconfig.json @@ -14,7 +14,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "baseUrl": "src" }, "include": ["src"] } diff --git a/react/yarn.lock b/react/yarn.lock index 0d71100..ea9771c 100644 --- a/react/yarn.lock +++ b/react/yarn.lock @@ -3700,6 +3700,11 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== + debug@2.6.9, debug@^2.6.0: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -7722,6 +7727,11 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== +prettier@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + pretty-bytes@^5.3.0, pretty-bytes@^5.4.1: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"