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 (
-
- );
-}
-
-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"