diff --git a/gs/backend/websocket_poc/__init__.py b/gs/backend/websocket_poc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gs/backend/websocket_poc/ws_server.py b/gs/backend/websocket_poc/ws_server.py new file mode 100644 index 000000000..c126a5e03 --- /dev/null +++ b/gs/backend/websocket_poc/ws_server.py @@ -0,0 +1,103 @@ +import asyncio +import json +from datetime import datetime + +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect + +app = FastAPI(title="WebSocket Terminal") + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket) -> None: + """ + Handle a WebSocket connection for the Raspberry Pi terminal. + + Receives commands from the client, executes them asynchronously on + the Raspberry Pi shell, and streams stdout/stderr back to the client + in real time, including status messages. + """ + + await websocket.accept() + print("New connection established.") + + try: + while True: + # Receive command from client + command = await websocket.receive_text() + print(f"Received command: {command}") + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Step 1: Send loading indicator + await websocket.send_text( + json.dumps({"type": "status", "message": "...", "timestamp": timestamp, "source": "raspberry-pi"}) + ) + + # Step 2: Run command on Raspberry Pi shell + process = await asyncio.create_subprocess_shell( + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + + # Step 3: Read and send output line by line + while True: + line = await process.stdout.readline() + if not line: + break + output = line.decode().strip() + if output: + await websocket.send_text( + json.dumps( + { + "type": "command", + "message": output, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "source": "raspberry-pi", + } + ) + ) + + # Step 4: Capture and send any errors + err = await process.stderr.read() + if err: + await websocket.send_text( + json.dumps( + { + "type": "error", + "message": err.decode().strip(), + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "source": "raspberry-pi", + } + ) + ) + + # Step 5: Notify completion + await websocket.send_text( + json.dumps( + { + "type": "status", + "message": f"Command '{command}' completed.", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "source": "raspberry-pi", + } + ) + ) + + except WebSocketDisconnect: + print("Client disconnected.") + except Exception as e: + print(f"Error: {e}") + await websocket.send_text( + json.dumps( + { + "type": "error", + "message": str(e), + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "source": "server", + } + ) + ) + + +if __name__ == "__main__": + uvicorn.run("backend.websocket_poc.ws_server:app", host="0.0.0.0", port=9067, reload=True) diff --git a/gs/frontend/mcc/package-lock.json b/gs/frontend/mcc/package-lock.json index 114768745..f6bfc0061 100644 --- a/gs/frontend/mcc/package-lock.json +++ b/gs/frontend/mcc/package-lock.json @@ -144,7 +144,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -494,7 +493,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -541,7 +539,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1171,7 +1168,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" }, @@ -2618,7 +2614,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2791,7 +2786,6 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2802,7 +2796,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2813,7 +2806,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2864,7 +2856,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -3232,7 +3223,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3413,7 +3403,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -3833,7 +3822,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4415,7 +4403,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -5082,7 +5069,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5166,7 +5152,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5176,7 +5161,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5667,7 +5651,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5812,7 +5795,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5941,7 +5923,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6056,7 +6037,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/gs/frontend/mcc/src/App.tsx b/gs/frontend/mcc/src/App.tsx index d5218024a..8650e78e4 100644 --- a/gs/frontend/mcc/src/App.tsx +++ b/gs/frontend/mcc/src/App.tsx @@ -6,6 +6,7 @@ import Dashboard from "./pages/Dashboard"; import AROAdmin from "./pages/AROAdmin"; import LiveSession from "./pages/LiveSession"; import Login from "./pages/Login"; +import WebsocketTerminal from "./pages/WebsocketTerminal"; /** * @brief App component displaying the main application @@ -22,6 +23,7 @@ function App() { } /> } /> } /> + } /> ); diff --git a/gs/frontend/mcc/src/pages/WebsocketTerminal.tsx b/gs/frontend/mcc/src/pages/WebsocketTerminal.tsx new file mode 100644 index 000000000..04b6c153b --- /dev/null +++ b/gs/frontend/mcc/src/pages/WebsocketTerminal.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useState } from "react"; + +interface Message { + text: string; + timestamp: string; + sender: "user" | "server"; +} + +const WebsocketTerminal: React.FC = () => { + const [socket, setSocket] = useState(null); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([]); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + // Connect to WebSocket backend + useEffect(() => { + // Adjust for connection when connecting to raspberry pi + const ws = new WebSocket("ws://localhost:9067/ws"); + setSocket(ws); + + ws.onopen = () => { + console.log("Connected to WebSocket backend"); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const { type, message, timestamp } = data; + + if (type === "command" || type === "status" || type === "error") { + setMessages((prev) => [ + ...prev, + { text: message, timestamp, sender: "server" }, + ]); + } + } catch { + // fallback for plain text + setMessages((prev) => [ + ...prev, + { + text: event.data, + timestamp: new Date().toLocaleString(), + sender: "server", + }, + ]); + } + }; + + ws.onclose = () => console.log("WebSocket disconnected"); + + return () => ws.close(); + }, []); + + // Auto-scroll when new messages come in + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Send command to Raspberry Pi + const sendCommand = () => { + if (socket && input.trim() !== "") { + const timestamp = new Date().toLocaleString(); + socket.send(input); + + setMessages((prev) => [ + ...prev, + { text: input, timestamp, sender: "user" }, + ]); + + setInput(""); + setTimeout(() => inputRef.current?.focus(), 50); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") sendCommand(); + }; + + const formatDateTime = (timestamp: string) => { + const date = new Date(timestamp); + const day = date.getDate(); + const month = date.toLocaleString("default", { month: "short" }); + const year = date.getFullYear(); + const time = date.toLocaleTimeString(); + return `${day} ${month} ${year} ${time}`; + }; + + return ( +
+ {/* Message Output */} +
+ {messages.map((msg, idx) => ( +
+ + [{formatDateTime(msg.timestamp)}] + {" "} + {msg.sender === "user" ? ( + <> + user@pi:~$ + {msg.text} + + ) : ( + <> + pi>{" "} + {msg.text} + + )} +
+ ))} +
+
+ + {/* Input Bar */} +
+ user@pi:~$ + setInput(e.target.value)} + onKeyDown={handleKeyPress} + autoFocus + /> +
+
+ ); +}; + +export default WebsocketTerminal; \ No newline at end of file diff --git a/gs/frontend/mcc/src/utils/nav-links.ts b/gs/frontend/mcc/src/utils/nav-links.ts index cf2cd65e1..4e5af28b0 100644 --- a/gs/frontend/mcc/src/utils/nav-links.ts +++ b/gs/frontend/mcc/src/utils/nav-links.ts @@ -24,4 +24,8 @@ export const NAVIGATION_LINKS: NavLink[] = [ text: "Live Sessions", url: "/live-sessions", }, + { + text: "Websocket Terminal", + url: "/websocket-terminal", + } ];