Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Testing websockets (pushing from fork to other branch) #2

Merged
merged 7 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions react/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore yarn.lock and other Yarn-specific files
yarn.lock
package.json
**/node_modules/*
3 changes: 3 additions & 0 deletions react/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"endOfLine": "lf"
}
4 changes: 3 additions & 1 deletion react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@
"@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",
"typescript": "^4.4.2",
"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",
Expand Down
38 changes: 0 additions & 38 deletions react/src/App.css

This file was deleted.

26 changes: 0 additions & 26 deletions react/src/App.tsx

This file was deleted.

44 changes: 44 additions & 0 deletions react/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="App">
<WebSocketProvider>
<Button name="Start" status={botStatus} onClick={startBot} />
<Button name="Bad Button" onClick={stopBot} />
<Console />
</WebSocketProvider>
</div>
);
}

export default App;
14 changes: 14 additions & 0 deletions react/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ButtonProps } from "types/button";
import "css/Button.css";
import StatusIndicator from "./StatusIndicator";

function Button(props: ButtonProps) {
return (
<button onClick={props.onClick} className="Button">
{<StatusIndicator status={props.status} />}
{props.name}
</button>
);
}

export default Button;
93 changes: 93 additions & 0 deletions react/src/components/Console.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const [socketHistory, setSocketHistory] = useState<string[]>([]);
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 (
<div className="Console">
<h1 className="ConsoleTitle">Console</h1>
<div
className="ConsoleBody"
ref={consoleOutputRef}
onScroll={checkEnableAutoScroll}
>
<pre>{renderConsoleText()}</pre>
</div>
</div>
);
}

export default Console;
7 changes: 7 additions & 0 deletions react/src/components/StatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Status } from "types/button";

function StatusIndicator(props: { status?: Status }) {
return <>{props.status && <span className={props.status} />}</>;
}

export default StatusIndicator;
106 changes: 106 additions & 0 deletions react/src/components/WebSocketContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
RosDataType,
SocketResponse,
WebSocketContextType,
} from "types/websocket";

const WebSocketContext = createContext<WebSocketContextType | undefined>(
undefined,
);

export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const [socket, setSocket] = useState<WebSocket | null>(null);
const listeners = useRef<Set<(data: SocketResponse) => 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 (
<WebSocketContext.Provider value={{ sendMessage, addMessageListener }}>
{children}
</WebSocketContext.Provider>
);
}

// 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<any | null>(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 };
}
Loading