diff --git a/backend/HMIWeb/MachineBridge.cs b/backend/HMIWeb/MachineBridge.cs index 515b02f..c1c4342 100644 --- a/backend/HMIWeb/MachineBridge.cs +++ b/backend/HMIWeb/MachineBridge.cs @@ -41,9 +41,25 @@ namespace HMIWeb _host.HandleCommand(command); } - public void LoadRecipe(string recipeId) + public string SelectRecipe(string recipeId) { - Console.WriteLine($"[C#] Loading Recipe: {recipeId}"); + Console.WriteLine($"[C#] Selecting Recipe: {recipeId}"); + + // In real app, load recipe settings here + // For now, just return success + try + { + // Simulate recipe loading logic + _host.SetCurrentRecipe(recipeId); + + var response = new { success = true, message = "Recipe selected successfully", recipeId = recipeId }; + return JsonConvert.SerializeObject(response); + } + catch (Exception ex) + { + var response = new { success = false, message = ex.Message }; + return JsonConvert.SerializeObject(response); + } } public string GetConfig() @@ -93,5 +109,49 @@ namespace HMIWeb Console.WriteLine($"[Backend] Data: {configJson}"); // In a real app, we would save this to a file or database } + public string GetIOList() + { + var ioList = new System.Collections.Generic.List(); + + // Outputs (0-31) + for (int i = 0; i < 32; i++) + { + string name = $"DOUT_{i:D2}"; + if (i == 0) name = "Tower Lamp Red"; + if (i == 1) name = "Tower Lamp Yel"; + if (i == 2) name = "Tower Lamp Grn"; + + ioList.Add(new { id = i, name = name, type = "output", state = false }); + } + + // Inputs (0-31) + for (int i = 0; i < 32; i++) + { + string name = $"DIN_{i:D2}"; + bool initialState = false; + if (i == 0) name = "Front Door Sensor"; + if (i == 1) name = "Right Door Sensor"; + if (i == 2) name = "Left Door Sensor"; + if (i == 3) name = "Back Door Sensor"; + if (i == 4) { name = "Main Air Pressure"; initialState = true; } + if (i == 5) { name = "Vacuum Generator"; initialState = true; } + if (i == 6) { name = "Emergency Stop Loop"; initialState = true; } + + ioList.Add(new { id = i, name = name, type = "input", state = initialState }); + } + + return Newtonsoft.Json.JsonConvert.SerializeObject(ioList); + } + public string GetRecipeList() + { + var recipes = new System.Collections.Generic.List(); + recipes.Add(new { id = "1", name = "Wafer_Proc_300_Au", lastModified = "2023-10-25" }); + recipes.Add(new { id = "2", name = "Wafer_Insp_200_Adv", lastModified = "2023-10-26" }); + recipes.Add(new { id = "3", name = "Glass_Gen5_Bonding", lastModified = "2023-10-27" }); + recipes.Add(new { id = "4", name = "Solar_Cell_Cut_A", lastModified = "2023-11-01" }); + recipes.Add(new { id = "5", name = "LED_Mount_HighSpeed", lastModified = "2023-11-15" }); + + return Newtonsoft.Json.JsonConvert.SerializeObject(recipes); + } } } diff --git a/backend/HMIWeb/MainForm.cs b/backend/HMIWeb/MainForm.cs index 99ae16b..cb34bbb 100644 --- a/backend/HMIWeb/MainForm.cs +++ b/backend/HMIWeb/MainForm.cs @@ -25,6 +25,7 @@ namespace HMIWeb private bool[] inputs = new bool[32]; private bool[] outputs = new bool[32]; private string systemState = "IDLE"; + private string currentRecipeId = "1"; // Default recipe public MainForm() { InitializeComponent(); @@ -77,7 +78,7 @@ namespace HMIWeb webView.CoreWebView2.AddHostObjectToScript("machine", new MachineBridge(this)); // 3. Load UI - //webView.Source = new Uri("http://hmi.local/index.html"); + webView.Source = new Uri("http://hmi.local/index.html"); // Disable default browser keys (F5, F12 etc) if needed for kiosk mode webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; @@ -153,6 +154,18 @@ namespace HMIWeb systemState = (cmd == "START") ? "RUNNING" : (cmd == "STOP") ? "PAUSED" : "IDLE"; } + public void SetCurrentRecipe(string recipeId) + { + currentRecipeId = recipeId; + Console.WriteLine($"[MainForm] Current recipe set to: {recipeId}"); + // In real app, load recipe parameters here + } + + public string GetCurrentRecipe() + { + return currentRecipeId; + } + private double Lerp(double a, double b, double t) => a + (b - a) * t; } } diff --git a/backend/HMIWeb/WebSocketServer.cs b/backend/HMIWeb/WebSocketServer.cs index 6db511f..1abdf4f 100644 --- a/backend/HMIWeb/WebSocketServer.cs +++ b/backend/HMIWeb/WebSocketServer.cs @@ -120,6 +120,7 @@ namespace HMIWeb dynamic json = Newtonsoft.Json.JsonConvert.DeserializeObject(msg); string type = json.type; + Console.WriteLine( $"HandleMessage:{type}" ); if (type == "GET_CONFIG") { // Simulate Delay for Loading Screen Test @@ -131,6 +132,20 @@ namespace HMIWeb var response = new { type = "CONFIG_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(configJson) }; await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response)); } + else if (type == "GET_IO_LIST") + { + var bridge = new MachineBridge(_mainForm); + string ioJson = bridge.GetIOList(); + var response = new { type = "IO_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(ioJson) }; + await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response)); + } + else if (type == "GET_RECIPE_LIST") + { + var bridge = new MachineBridge(_mainForm); + string recipeJson = bridge.GetRecipeList(); + var response = new { type = "RECIPE_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(recipeJson) }; + await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response)); + } else if (type == "SAVE_CONFIG") { string configJson = Newtonsoft.Json.JsonConvert.SerializeObject(json.data); @@ -154,6 +169,14 @@ namespace HMIWeb bool state = json.state; _mainForm.Invoke(new Action(() => _mainForm.SetOutput(id, state))); } + else if (type == "SELECT_RECIPE") + { + string recipeId = json.recipeId; + var bridge = new MachineBridge(_mainForm); + string resultJson = bridge.SelectRecipe(recipeId); + var response = new { type = "RECIPE_SELECTED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) }; + await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response)); + } } catch (Exception ex) { diff --git a/frontend/App.tsx b/frontend/App.tsx index 5eeb9dc..1cac6cf 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,26 +1,13 @@ - import React, { useState, useEffect, useRef } from 'react'; -import { - Activity, Settings, Move, Camera, Play, Square, RotateCw, - Cpu, AlertTriangle, Siren, Terminal, Layers -} from 'lucide-react'; -import { Machine3D } from './components/Machine3D'; -import { SettingsModal } from './components/SettingsModal'; -import { RecipePanel } from './components/RecipePanel'; -import { IOPanel } from './components/IOPanel'; -import { MotionPanel } from './components/MotionPanel'; -import { CameraPanel } from './components/CameraPanel'; -import { CyberPanel } from './components/common/CyberPanel'; -import { TechButton } from './components/common/TechButton'; +import { HashRouter, Routes, Route } from 'react-router-dom'; +import { Layout } from './components/layout/Layout'; +import { HomePage } from './pages/HomePage'; +import { IOMonitorPage } from './pages/IOMonitorPage'; import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types'; import { comms } from './communication'; // --- MOCK DATA --- -const MOCK_RECIPES: Recipe[] = [ - { id: '1', name: 'Wafer_Proc_300_Au', lastModified: '2023-10-25' }, - { id: '2', name: 'Wafer_Insp_200_Adv', lastModified: '2023-10-26' }, - { id: '3', name: 'Glass_Gen5_Bonding', lastModified: '2023-10-27' }, -]; + const INITIAL_IO: IOPoint[] = [ ...Array.from({ length: 32 }, (_, i) => { @@ -47,34 +34,35 @@ const INITIAL_IO: IOPoint[] = [ // --- MAIN APP --- export default function App() { - const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null); + const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | null>(null); + const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in'); const [systemState, setSystemState] = useState(SystemState.IDLE); - const [currentRecipe, setCurrentRecipe] = useState(MOCK_RECIPES[0]); + const [recipes, setRecipes] = useState([]); + const [currentRecipe, setCurrentRecipe] = useState(null); const [robotTarget, setRobotTarget] = useState({ x: 0, y: 0, z: 0 }); const [logs, setLogs] = useState([]); - const [ioPoints, setIoPoints] = useState(INITIAL_IO); + const [ioPoints, setIoPoints] = useState([]); const [currentTime, setCurrentTime] = useState(new Date()); const [config, setConfig] = useState(null); const [isConfigRefreshing, setIsConfigRefreshing] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const videoRef = useRef(null); - - // Check if running in WebView2 context - const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview; + const [isHostConnected, setIsHostConnected] = useState(false); + const videoRef = useRef(null); // -- COMMUNICATION LAYER -- useEffect(() => { - // Subscribe to unified communication layer const unsubscribe = comms.subscribe((msg: any) => { if (!msg) return; + if (msg.type === 'CONNECTION_STATE') { + setIsHostConnected(msg.connected); + addLog(msg.connected ? "HOST CONNECTED" : "HOST DISCONNECTED", msg.connected ? "info" : "warning"); + } + if (msg.type === 'STATUS_UPDATE') { - // Update Motion State if (msg.position) { setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z }); } - // Update IO State (Merge with existing names/configs) if (msg.ioState) { setIoPoints(prev => { const newIO = [...prev]; @@ -85,7 +73,6 @@ export default function App() { return newIO; }); } - // Update System State if (msg.sysState) { setSystemState(msg.sysState as SystemState); } @@ -93,8 +80,8 @@ export default function App() { }); addLog("COMMUNICATION CHANNEL OPEN", "info"); + setIsHostConnected(comms.getConnectionState()); - // Timer for Clock const timer = setInterval(() => setCurrentTime(new Date()), 1000); return () => { clearInterval(timer); @@ -105,30 +92,39 @@ export default function App() { // -- INITIALIZATION -- useEffect(() => { const initSystem = async () => { - // Just start up without fetching config addLog("SYSTEM STARTED", "info"); + try { + const ioStr = await comms.getIOList(); + const ioData = JSON.parse(ioStr); + setIoPoints(ioData); + addLog("IO LIST LOADED", "info"); + + const recipeStr = await comms.getRecipeList(); + const recipeData = JSON.parse(recipeStr); + setRecipes(recipeData); + if (recipeData.length > 0) setCurrentRecipe(recipeData[0]); + addLog("RECIPE LIST LOADED", "info"); + } catch (e) { + addLog("FAILED TO LOAD SYSTEM DATA", "error"); + // Fallback to empty or keep initial if needed + } setIsLoading(false); }; initSystem(); }, []); - // -- ON-DEMAND CONFIG FETCH -- - useEffect(() => { - if (activeTab === 'setting') { - const fetchConfig = async () => { - setIsConfigRefreshing(true); - try { - const configStr = await comms.getConfig(); - setConfig(JSON.parse(configStr)); - addLog("CONFIG REFRESHED", "info"); - } catch (e) { - addLog("CONFIG REFRESH FAILED", "error"); - } - setIsConfigRefreshing(false); - }; - fetchConfig(); + // -- CONFIG FETCHING (for settings modal) -- + const fetchConfig = React.useCallback(async () => { + setIsConfigRefreshing(true); + try { + const configStr = await comms.getConfig(); + setConfig(JSON.parse(configStr)); + addLog("CONFIG REFRESHED", "info"); + } catch (e) { + addLog("CONFIG REFRESH FAILED", "error"); } - }, [activeTab]); + setIsConfigRefreshing(false); + }, []); const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => { setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50)); @@ -146,8 +142,6 @@ export default function App() { // -- COMMAND HANDLERS -- - // -- COMMAND HANDLERS -- - const handleControl = async (action: 'start' | 'stop' | 'reset') => { if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error'); @@ -160,7 +154,6 @@ export default function App() { }; const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => { - // Only allow output toggling if (type === 'output') { const current = ioPoints.find(p => p.id === id && p.type === type)?.state; const nextState = forceState !== undefined ? forceState : !current; @@ -174,215 +167,87 @@ export default function App() { addLog(`CMD MOVE ${axis}: ${value}`, 'info'); }; - useEffect(() => { - if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) { - navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s }); - } - }, [activeTab]); - const handleSaveConfig = async (newConfig: ConfigItem[]) => { try { await comms.saveConfig(JSON.stringify(newConfig)); setConfig(newConfig); - addLog("CONFIGURATION SAVED", "success"); + addLog("CONFIGURATION SAVED", "info"); } catch (e) { console.error(e); addLog("FAILED TO SAVE CONFIG", "error"); } }; + const handleSelectRecipe = async (r: Recipe) => { + try { + addLog(`LOADING: ${r.name}`, 'info'); + const result = await comms.selectRecipe(r.id); + + if (result.success) { + setCurrentRecipe(r); + addLog(`RECIPE LOADED: ${r.name}`, 'info'); + } else { + addLog(`RECIPE LOAD FAILED: ${result.message}`, 'error'); + } + } catch (error: any) { + addLog(`RECIPE LOAD ERROR: ${error.message || 'Unknown error'}`, 'error'); + console.error('Recipe selection error:', error); + } + }; + return ( -
- - {/* Animated Nebula Background */} -
-
-
- - {/* LOADING OVERLAY */} - {isLoading && ( -
-
-
-
- -
-
-

SYSTEM INITIALIZING

-

ESTABLISHING CONNECTION...

-
-
- )} - - {/* HEADER */} -
-
-
- -
-
-

- EQUI-HANDLER PRO -

-
- SYS.VER 4.2.0 - | - - LINK: {isWebView ? "NATIVE" : "SIMULATION"} - -
-
-
- - {/* Top Navigation */} -
- {[ - { id: 'recipe', icon: Layers, label: 'RECIPE' }, - { id: 'io', icon: Activity, label: 'I/O MONITOR' }, - { id: 'motion', icon: Move, label: 'MOTION' }, - { id: 'camera', icon: Camera, label: 'VISION' }, - { id: 'setting', icon: Settings, label: 'CONFIG' } - ].map(item => ( - - ))} -
- -
-
- {currentTime.toLocaleTimeString('en-GB')} -
-
- {currentTime.toLocaleDateString().toUpperCase()} -
-
-
- - {/* MAIN CONTENT */} -
- - {/* 3D Canvas (Background Layer) */} -
- -
- - {/* Center Alarms */} -
- {isEmergencyStop && ( -
- -
-

EMERGENCY STOP

-

SYSTEM HALTED - RELEASE TO RESET

-
-
- )} - {isLowPressure && !isEmergencyStop && ( -
- LOW AIR PRESSURE WARNING -
- )} -
- - {/* Floating Panel (Left) */} - {activeTab && activeTab !== 'setting' && ( -
- - {activeTab === 'recipe' && { setCurrentRecipe(r); addLog(`LOADED: ${r.name}`) }} />} - {activeTab === 'io' && } - {activeTab === 'motion' && } - {activeTab === 'camera' && } - -
- )} - - {/* Settings Modal */} - setActiveTab(null)} - config={config} - isRefreshing={isConfigRefreshing} - onSave={handleSaveConfig} - /> - - {/* Right Sidebar (Dashboard) */} -
- -
System Status
-
- {systemState} -
-
- handleControl('start')} - > - START AUTO - - handleControl('stop')} - > - STOP / PAUSE - - handleControl('reset')} - > - SYSTEM RESET - -
-
- - -
- Event Log - -
-
- {logs.map(log => ( -
- [{log.timestamp}] - {log.message} -
- ))} -
-
-
- -
- - {/* FOOTER STATUS */} -
-
- {['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => ( -
-
- {hw} -
- ))} -
-
- POS.X: {robotTarget.x.toFixed(3)} - POS.Y: {robotTarget.y.toFixed(3)} - POS.Z: {robotTarget.z.toFixed(3)} -
-
- -
+ + { + setActiveTab(tab); + if (tab === null) setActiveIOTab('in'); // Reset IO tab when closing + }} + activeTab={activeTab} + isLoading={isLoading} + > + + setActiveTab(null)} + videoRef={videoRef} + /> + } + /> + + } + /> + + + ); } diff --git a/frontend/communication.ts b/frontend/communication.ts index ffc05ca..0f39398 100644 --- a/frontend/communication.ts +++ b/frontend/communication.ts @@ -12,6 +12,7 @@ class CommunicationLayer { constructor() { if (isWebView) { console.log("[COMM] Running in WebView2 Mode"); + this.isConnected = true; // WebView2 is always connected window.chrome.webview.addEventListener('message', (event: any) => { this.notifyListeners(event.data); }); @@ -27,6 +28,7 @@ class CommunicationLayer { this.ws.onopen = () => { console.log("[COMM] WebSocket Connected"); this.isConnected = true; + this.notifyListeners({ type: 'CONNECTION_STATE', connected: true }); }; this.ws.onmessage = (event) => { @@ -41,6 +43,7 @@ class CommunicationLayer { this.ws.onclose = () => { console.log("[COMM] WebSocket Closed. Reconnecting..."); this.isConnected = false; + this.notifyListeners({ type: 'CONNECTION_STATE', connected: false }); setTimeout(() => this.connectWebSocket(), 2000); }; @@ -60,6 +63,10 @@ class CommunicationLayer { }; } + public getConnectionState(): boolean { + return this.isConnected; + } + // --- API Methods --- public async getConfig(): Promise { @@ -95,6 +102,64 @@ class CommunicationLayer { } } + public async getIOList(): Promise { + if (isWebView) { + return await window.chrome.webview.hostObjects.machine.GetIOList(); + } else { + return new Promise((resolve, reject) => { + if (!this.isConnected) { + setTimeout(() => { + if (!this.isConnected) reject("WebSocket connection timeout"); + }, 2000); + } + + const timeoutId = setTimeout(() => { + this.listeners = this.listeners.filter(cb => cb !== handler); + reject("IO fetch timeout"); + }, 10000); + + const handler = (data: any) => { + if (data.type === 'IO_LIST_DATA') { + clearTimeout(timeoutId); + this.listeners = this.listeners.filter(cb => cb !== handler); + resolve(JSON.stringify(data.data)); + } + }; + this.listeners.push(handler); + this.ws?.send(JSON.stringify({ type: 'GET_IO_LIST' })); + }); + } + } + + public async getRecipeList(): Promise { + if (isWebView) { + return await window.chrome.webview.hostObjects.machine.GetRecipeList(); + } else { + return new Promise((resolve, reject) => { + if (!this.isConnected) { + setTimeout(() => { + if (!this.isConnected) reject("WebSocket connection timeout"); + }, 2000); + } + + const timeoutId = setTimeout(() => { + this.listeners = this.listeners.filter(cb => cb !== handler); + reject("Recipe fetch timeout"); + }, 10000); + + const handler = (data: any) => { + if (data.type === 'RECIPE_LIST_DATA') { + clearTimeout(timeoutId); + this.listeners = this.listeners.filter(cb => cb !== handler); + resolve(JSON.stringify(data.data)); + } + }; + this.listeners.push(handler); + this.ws?.send(JSON.stringify({ type: 'GET_RECIPE_LIST' })); + }); + } + } + public async saveConfig(configJson: string): Promise { if (isWebView) { await window.chrome.webview.hostObjects.machine.SaveConfig(configJson); @@ -126,6 +191,36 @@ class CommunicationLayer { this.ws?.send(JSON.stringify({ type: 'SET_IO', id, state })); } } + + public async selectRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> { + if (isWebView) { + const resultJson = await window.chrome.webview.hostObjects.machine.SelectRecipe(recipeId); + return JSON.parse(resultJson); + } else { + return new Promise((resolve, reject) => { + if (!this.isConnected) { + setTimeout(() => { + if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" }); + }, 2000); + } + + const timeoutId = setTimeout(() => { + this.listeners = this.listeners.filter(cb => cb !== handler); + reject({ success: false, message: "Recipe selection timeout" }); + }, 10000); + + const handler = (data: any) => { + if (data.type === 'RECIPE_SELECTED') { + clearTimeout(timeoutId); + this.listeners = this.listeners.filter(cb => cb !== handler); + resolve(data.data); + } + }; + this.listeners.push(handler); + this.ws?.send(JSON.stringify({ type: 'SELECT_RECIPE', recipeId })); + }); + } + } } export const comms = new CommunicationLayer(); diff --git a/frontend/components/ModelInfoPanel.tsx b/frontend/components/ModelInfoPanel.tsx new file mode 100644 index 0000000..6f1c66b --- /dev/null +++ b/frontend/components/ModelInfoPanel.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Box, Cpu, Activity } from 'lucide-react'; +import { Recipe } from '../types'; +import { CyberPanel } from './common/CyberPanel'; + +interface ModelInfoPanelProps { + currentRecipe: Recipe; +} + +export const ModelInfoPanel: React.FC = ({ currentRecipe }) => { + return ( + +
+ Model Information + +
+ +
+
+
SELECTED MODEL
+
+ + {currentRecipe.name} +
+
+ +
+
+
Model ID
+
{currentRecipe.id}
+
+
+
Last Mod
+
{currentRecipe.lastModified}
+
+
+ +
+
+ TARGET CYCLE + 12.5s +
+
+
+
+ +
+ EST. YIELD + 99.8% +
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/components/RecipePanel.tsx b/frontend/components/RecipePanel.tsx index de9215f..c7ff6c6 100644 --- a/frontend/components/RecipePanel.tsx +++ b/frontend/components/RecipePanel.tsx @@ -1,40 +1,84 @@ -import React from 'react'; -import { FileText, CheckCircle2, Layers } from 'lucide-react'; +import React, { useState } from 'react'; +import { Layers, Check, Settings, X } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { Recipe } from '../types'; -import { PanelHeader } from './common/PanelHeader'; import { TechButton } from './common/TechButton'; interface RecipePanelProps { recipes: Recipe[]; currentRecipe: Recipe; onSelectRecipe: (r: Recipe) => void; + onClose: () => void; } -export const RecipePanel: React.FC = ({ recipes, currentRecipe, onSelectRecipe }) => ( -
- -
- {recipes.map(r => ( -
onSelectRecipe(r)} - className={` - p-3 cursor-pointer flex justify-between items-center transition-all border-l-4 - ${currentRecipe.id === r.id - ? 'bg-white/10 border-neon-blue text-white shadow-[inset_0_0_20px_rgba(0,243,255,0.1)]' - : 'bg-black/20 border-transparent text-slate-500 hover:bg-white/5 hover:text-slate-300'} - `} - > -
-
{r.name}
-
{r.lastModified}
+export const RecipePanel: React.FC = ({ recipes, currentRecipe, onSelectRecipe, onClose }) => { + const navigate = useNavigate(); + const [selectedId, setSelectedId] = useState(currentRecipe.id); + + const handleConfirm = () => { + const selected = recipes.find(r => r.id === selectedId); + if (selected) { + onSelectRecipe(selected); + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ RECIPE SELECTION
- {currentRecipe.id === r.id && } +
- ))} + + {/* List (Max height for ~5 items) */} +
+
+ {recipes.map(recipe => ( +
setSelectedId(recipe.id)} + className={` + p-3 rounded border cursor-pointer transition-all flex items-center justify-between + ${selectedId === recipe.id + ? 'bg-neon-blue/20 border-neon-blue text-white shadow-[0_0_10px_rgba(0,243,255,0.2)]' + : 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20'} + `} + > +
+
{recipe.name}
+
ID: {recipe.id} | MOD: {recipe.lastModified}
+
+ {selectedId === recipe.id && } +
+ ))} +
+
+ + {/* Footer Actions */} +
+ navigate('/recipe')} + > + MANAGEMENT + + + + SELECT + +
+
- - New Recipe - -
-); + ); +}; diff --git a/frontend/components/common/TechButton.tsx b/frontend/components/common/TechButton.tsx index ccb6fba..ed0fdad 100644 --- a/frontend/components/common/TechButton.tsx +++ b/frontend/components/common/TechButton.tsx @@ -4,8 +4,10 @@ interface TechButtonProps { children?: React.ReactNode; onClick?: () => void; active?: boolean; - variant?: 'blue' | 'red' | 'amber' | 'green'; + variant?: 'blue' | 'red' | 'amber' | 'green' | 'default' | 'danger'; className?: string; + disabled?: boolean; + title?: string; } export const TechButton: React.FC = ({ @@ -13,26 +15,39 @@ export const TechButton: React.FC = ({ onClick, active = false, variant = 'blue', - className = '' + className = '', + disabled = false, + title }) => { const colors = { blue: 'from-blue-600 to-cyan-600 hover:shadow-glow-blue border-cyan-400/30', red: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30', amber: 'from-amber-500 to-orange-600 hover:shadow-orange-500/50 border-orange-400/30', - green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30' + green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30', + default: 'from-slate-600 to-slate-500 hover:shadow-slate-500/50 border-slate-400/30', + danger: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30' }; + const variantKey = variant === 'danger' ? 'red' : (variant === 'default' ? 'blue' : variant); + return ( ); diff --git a/frontend/components/layout/Footer.tsx b/frontend/components/layout/Footer.tsx new file mode 100644 index 0000000..d3b417f --- /dev/null +++ b/frontend/components/layout/Footer.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { RobotTarget } from '../../types'; + +interface FooterProps { + isHostConnected: boolean; + robotTarget: RobotTarget; +} + +export const Footer: React.FC = ({ isHostConnected, robotTarget }) => { + return ( +
+
+ {['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => ( +
+
+ {hw} +
+ ))} +
+
+ HOST +
+
+
+ POS.X: {robotTarget.x.toFixed(3)} + POS.Y: {robotTarget.y.toFixed(3)} + POS.Z: {robotTarget.z.toFixed(3)} +
+
+ ); +}; diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx new file mode 100644 index 0000000..4037085 --- /dev/null +++ b/frontend/components/layout/Header.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Activity, Settings, Move, Camera, Layers, Cpu } from 'lucide-react'; + +interface HeaderProps { + currentTime: Date; + onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void; + activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null; + +} + +export const Header: React.FC = ({ currentTime, onTabChange, activeTab }) => { + const navigate = useNavigate(); + const location = useLocation(); + + const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview; + const isIOPage = location.pathname === '/io-monitor'; + + return ( +
+
{ + navigate('/'); + onTabChange(null); + }} + > +
+ +
+
+

+ EQUI-HANDLER PRO +

+
+ SYS.VER 4.2.0 + | + + LINK: {isWebView ? "NATIVE" : "SIMULATION"} + +
+
+
+ + {/* Top Navigation */} +
+ {/* IO Tab Switcher (only on IO page) */} + + +
+ {[ + { id: 'recipe', icon: Layers, label: 'RECIPE', path: '/' }, + { id: 'io', icon: Activity, label: 'I/O MONITOR', path: '/io-monitor' }, + { id: 'motion', icon: Move, label: 'MOTION', path: '/' }, + { id: 'camera', icon: Camera, label: 'VISION', path: '/' }, + { id: 'setting', icon: Settings, label: 'CONFIG', path: '/' } + ].map(item => { + const isActive = item.id === 'io' + ? location.pathname === '/io-monitor' + : activeTab === item.id; + + return ( + + ); + })} +
+
+ +
+
+ {currentTime.toLocaleTimeString('en-GB')} +
+
+ {currentTime.toLocaleDateString().toUpperCase()} +
+
+
+ ); +}; diff --git a/frontend/components/layout/Layout.tsx b/frontend/components/layout/Layout.tsx new file mode 100644 index 0000000..4c18e09 --- /dev/null +++ b/frontend/components/layout/Layout.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Header } from './Header'; +import { Footer } from './Footer'; +import { RobotTarget } from '../../types'; + +interface LayoutProps { + children: React.ReactNode; + currentTime: Date; + isHostConnected: boolean; + robotTarget: RobotTarget; + onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void; + activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null; + isLoading: boolean; +} + +export const Layout: React.FC = ({ + children, + currentTime, + isHostConnected, + robotTarget, + onTabChange, + activeTab, + isLoading +}) => { + return ( +
+ {/* Animated Nebula Background */} +
+
+
+ + {/* LOADING OVERLAY */} + {isLoading && ( +
+
+
+
+
+
+
+

SYSTEM INITIALIZING

+

ESTABLISHING CONNECTION...

+
+
+ )} + +
+ + {/* Main Content Area */} +
+ {children} +
+ +
+
+ ); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1a3888e..5216068 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "lucide-react": "^0.303.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^7.9.6", "tailwind-merge": "^2.2.0", "three": "^0.160.0" }, @@ -1800,6 +1801,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -2801,6 +2811,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-use-measure": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", @@ -2965,6 +3013,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a5fcdb2..4e69bc7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "lucide-react": "^0.303.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^7.9.6", "tailwind-merge": "^2.2.0", "three": "^0.160.0" }, diff --git a/frontend/pages/HomePage.tsx b/frontend/pages/HomePage.tsx new file mode 100644 index 0000000..cd7e0c6 --- /dev/null +++ b/frontend/pages/HomePage.tsx @@ -0,0 +1,174 @@ +import React, { useState, useEffect } from 'react'; +import { Play, Square, RotateCw, AlertTriangle, Siren, Terminal } from 'lucide-react'; +import { Machine3D } from '../components/Machine3D'; +import { SettingsModal } from '../components/SettingsModal'; +import { RecipePanel } from '../components/RecipePanel'; +import { MotionPanel } from '../components/MotionPanel'; +import { CameraPanel } from '../components/CameraPanel'; +import { CyberPanel } from '../components/common/CyberPanel'; +import { TechButton } from '../components/common/TechButton'; +import { ModelInfoPanel } from '../components/ModelInfoPanel'; +import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from '../types'; + +interface HomePageProps { + systemState: SystemState; + currentRecipe: Recipe; + recipes: Recipe[]; + robotTarget: RobotTarget; + logs: LogEntry[]; + ioPoints: IOPoint[]; + config: ConfigItem[] | null; + isConfigRefreshing: boolean; + doorStates: { front: boolean; right: boolean; left: boolean; back: boolean }; + isLowPressure: boolean; + isEmergencyStop: boolean; + activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null; + onSelectRecipe: (r: Recipe) => void; + onMove: (axis: 'X' | 'Y' | 'Z', val: number) => void; + onControl: (action: 'start' | 'stop' | 'reset') => void; + onSaveConfig: (config: ConfigItem[]) => void; + onFetchConfig: () => void; + onCloseTab: () => void; + videoRef: React.RefObject; +} + +export const HomePage: React.FC = ({ + systemState, + currentRecipe, + recipes, + robotTarget, + logs, + ioPoints, + config, + isConfigRefreshing, + doorStates, + isLowPressure, + isEmergencyStop, + activeTab, + onSelectRecipe, + onMove, + onControl, + onSaveConfig, + onFetchConfig, + onCloseTab, + videoRef +}) => { + useEffect(() => { + if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) { + navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s }); + } + }, [activeTab, videoRef]); + + useEffect(() => { + if (activeTab === 'setting') { + onFetchConfig(); + } + }, [activeTab, onFetchConfig]); + + return ( +
+ {/* 3D Canvas (Background Layer) */} +
+ +
+ + {/* Center Alarms */} +
+ {isEmergencyStop && ( +
+ +
+

EMERGENCY STOP

+

SYSTEM HALTED - RELEASE TO RESET

+
+
+ )} + {isLowPressure && !isEmergencyStop && ( +
+ LOW AIR PRESSURE WARNING +
+ )} +
+ + {/* Floating Panel (Left) */} + {activeTab && activeTab !== 'setting' && activeTab !== 'recipe' && ( +
+ + {activeTab === 'motion' && } + {activeTab === 'camera' && } + +
+ )} + + {/* Recipe Selection Modal */} + {activeTab === 'recipe' && ( + + )} + + {/* Settings Modal */} + + + {/* Right Sidebar (Dashboard) */} +
+ + + +
System Status
+
+ {systemState} +
+
+ onControl('start')} + > + START AUTO + + onControl('stop')} + > + STOP / PAUSE + + onControl('reset')} + > + SYSTEM RESET + +
+
+ + +
+ Event Log + +
+
+ {logs.map(log => ( +
+ [{log.timestamp}] + {log.message} +
+ ))} +
+
+
+
+ ); +}; diff --git a/frontend/pages/IOMonitorPage.tsx b/frontend/pages/IOMonitorPage.tsx new file mode 100644 index 0000000..54441c7 --- /dev/null +++ b/frontend/pages/IOMonitorPage.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { IOPoint } from '../types'; + +interface IOMonitorPageProps { + ioPoints: IOPoint[]; + onToggle: (id: number, type: 'input' | 'output') => void; + activeIOTab: 'in' | 'out'; + onIOTabChange: (tab: 'in' | 'out') => void; +} + +export const IOMonitorPage: React.FC = ({ ioPoints, onToggle, activeIOTab, onIOTabChange }) => { + const points = ioPoints.filter(p => p.type === (activeIOTab === 'in' ? 'input' : 'output')); + + return ( +
+
+ + {/* Local Header / Controls */} +
+
+

+ SYSTEM I/O MONITOR +

+
+
+ TOTAL POINTS: {ioPoints.length} +
+
+ +
+ + +
+
+ +
+ {/* Grid Layout - More columns for full screen */} + {/* Grid Layout - 2 Columns for list view */} +
+ {points.map(p => ( +
onToggle(p.id, p.type)} + className={` + flex items-center gap-4 px-4 py-3 cursor-pointer transition-all border + clip-tech-sm group hover:translate-x-1 + ${p.state + ? (p.type === 'output' + ? 'bg-neon-green/10 border-neon-green text-neon-green shadow-[0_0_15px_rgba(10,255,0,0.2)]' + : 'bg-neon-yellow/10 border-neon-yellow text-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.2)]') + : 'bg-slate-900/40 border-slate-800 text-slate-500 hover:border-slate-600 hover:bg-slate-800/40'} + `} + > + {/* LED Indicator */} +
+ + {/* ID Badge */} +
+ {p.type === 'input' ? 'I' : 'Q'}{p.id.toString().padStart(2, '0')} +
+ + {/* Name */} +
+ {p.name} +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/frontend/pages/RecipePage.tsx b/frontend/pages/RecipePage.tsx new file mode 100644 index 0000000..0591898 --- /dev/null +++ b/frontend/pages/RecipePage.tsx @@ -0,0 +1,173 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Layers, Plus, Trash2, Copy, Save, ArrowLeft, FileText, Calendar } from 'lucide-react'; +import { Recipe } from '../types'; +import { comms } from '../communication'; +import { TechButton } from '../components/common/TechButton'; + +export const RecipePage: React.FC = () => { + const navigate = useNavigate(); + const [recipes, setRecipes] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [editedRecipe, setEditedRecipe] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadRecipes = async () => { + try { + const recipeStr = await comms.getRecipeList(); + const recipeData = JSON.parse(recipeStr); + setRecipes(recipeData); + if (recipeData.length > 0) { + setSelectedId(recipeData[0].id); + setEditedRecipe(recipeData[0]); + } + } catch (e) { + console.error("Failed to load recipes", e); + } + setIsLoading(false); + }; + loadRecipes(); + }, []); + + const handleSelect = (r: Recipe) => { + setSelectedId(r.id); + setEditedRecipe({ ...r }); + }; + + const handleSave = async () => { + // Mock Save Logic + console.log("Saving recipe:", editedRecipe); + // In real app: await comms.saveRecipe(editedRecipe); + }; + + return ( +
+ {/* Header */} +
+
+ +

+ RECIPE MANAGEMENT +

+
+
+ TOTAL: {recipes.length} +
+
+ +
+ {/* Left: Recipe List */} +
+
+
+ RECIPE LIST +
+
+ {recipes.map(r => ( +
handleSelect(r)} + className={` + p-3 rounded border cursor-pointer transition-all group + ${selectedId === r.id + ? 'bg-neon-blue/20 border-neon-blue text-white shadow-glow-blue' + : 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:border-white/20'} + `} + > +
{r.name}
+
+ {r.lastModified} +
+
+ ))} +
+ + {/* List Actions */} +
+ + + + + + + + + +
+
+
+ + {/* Right: Editor */} +
+
+ RECIPE EDITOR + {editedRecipe && {editedRecipe.id}} +
+ +
+ {editedRecipe ? ( +
+
+ + setEditedRecipe({ ...editedRecipe, name: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" + /> +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + {/* Placeholder for more parameters */} +
+ Additional process parameters would go here... +
+
+ ) : ( +
+ +

Select a recipe to edit

+
+ )} +
+ + {/* Editor Actions */} +
+ + SAVE CHANGES + +
+
+
+
+ ); +}; diff --git a/frontend/types.ts b/frontend/types.ts index 1913e8a..f7fab75 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -61,6 +61,8 @@ declare global { SystemControl(command: string): Promise; LoadRecipe(recipeId: string): Promise; GetConfig(): Promise; + GetIOList(): Promise; + GetRecipeList(): Promise; SaveConfig(configJson: string): Promise; } };