Backend changes (C#): - Add SelectRecipe method to MachineBridge for recipe selection - Add currentRecipeId tracking in MainForm - Implement SELECT_RECIPE handler in WebSocketServer Frontend changes (React/TypeScript): - Add selectRecipe method to communication layer - Update handleSelectRecipe to call backend and handle response - Recipe selection updates ModelInfoPanel automatically - Add error handling and logging for recipe operations Layout improvements: - Add Layout component with persistent Header and Footer - Create separate IOMonitorPage for full-screen I/O monitoring - Add dynamic IO tab switcher in Header (Inputs/Outputs) - Ensure consistent UI across all pages 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
8.9 KiB
TypeScript
254 lines
8.9 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
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 INITIAL_IO: IOPoint[] = [
|
|
...Array.from({ length: 32 }, (_, i) => {
|
|
let name = `DOUT_${i.toString().padStart(2, '0')}`;
|
|
if (i === 0) name = "Tower Lamp Red";
|
|
if (i === 1) name = "Tower Lamp Yel";
|
|
if (i === 2) name = "Tower Lamp Grn";
|
|
return { id: i, name, type: 'output' as const, state: false };
|
|
}),
|
|
...Array.from({ length: 32 }, (_, i) => {
|
|
let name = `DIN_${i.toString().padStart(2, '0')}`;
|
|
let 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; }
|
|
return { id: i, name, type: 'input' as const, state: initialState };
|
|
})
|
|
];
|
|
|
|
// --- MAIN APP ---
|
|
|
|
export default function App() {
|
|
const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | null>(null);
|
|
const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in');
|
|
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
|
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
|
const [currentRecipe, setCurrentRecipe] = useState<Recipe | null>(null);
|
|
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
const [config, setConfig] = useState<ConfigItem[] | null>(null);
|
|
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isHostConnected, setIsHostConnected] = useState(false);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
// -- COMMUNICATION LAYER --
|
|
useEffect(() => {
|
|
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') {
|
|
if (msg.position) {
|
|
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
|
|
}
|
|
if (msg.ioState) {
|
|
setIoPoints(prev => {
|
|
const newIO = [...prev];
|
|
msg.ioState.forEach((update: { id: number, type: string, state: boolean }) => {
|
|
const idx = newIO.findIndex(p => p.id === update.id && p.type === update.type);
|
|
if (idx >= 0) newIO[idx] = { ...newIO[idx], state: update.state };
|
|
});
|
|
return newIO;
|
|
});
|
|
}
|
|
if (msg.sysState) {
|
|
setSystemState(msg.sysState as SystemState);
|
|
}
|
|
}
|
|
});
|
|
|
|
addLog("COMMUNICATION CHANNEL OPEN", "info");
|
|
setIsHostConnected(comms.getConnectionState());
|
|
|
|
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
|
return () => {
|
|
clearInterval(timer);
|
|
unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
// -- INITIALIZATION --
|
|
useEffect(() => {
|
|
const initSystem = async () => {
|
|
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();
|
|
}, []);
|
|
|
|
// -- 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");
|
|
}
|
|
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));
|
|
};
|
|
|
|
// Logic Helpers
|
|
const doorStates = {
|
|
front: ioPoints.find(p => p.id === 0 && p.type === 'input')?.state || false,
|
|
right: ioPoints.find(p => p.id === 1 && p.type === 'input')?.state || false,
|
|
left: ioPoints.find(p => p.id === 2 && p.type === 'input')?.state || false,
|
|
back: ioPoints.find(p => p.id === 3 && p.type === 'input')?.state || false,
|
|
};
|
|
const isLowPressure = !(ioPoints.find(p => p.id === 4 && p.type === 'input')?.state ?? true);
|
|
const isEmergencyStop = !(ioPoints.find(p => p.id === 6 && p.type === 'input')?.state ?? true);
|
|
|
|
// -- COMMAND HANDLERS --
|
|
|
|
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
|
|
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
|
|
|
|
try {
|
|
await comms.sendControl(action.toUpperCase());
|
|
addLog(`CMD SENT: ${action.toUpperCase()}`, 'info');
|
|
} catch (e) {
|
|
addLog('COMM ERROR', 'error');
|
|
}
|
|
};
|
|
|
|
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
|
|
if (type === 'output') {
|
|
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
|
|
const nextState = forceState !== undefined ? forceState : !current;
|
|
await comms.setIO(id, nextState);
|
|
}
|
|
};
|
|
|
|
const moveAxis = async (axis: 'X' | 'Y' | 'Z', value: number) => {
|
|
if (isEmergencyStop) return;
|
|
await comms.moveAxis(axis, value);
|
|
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
|
|
};
|
|
|
|
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
|
|
try {
|
|
await comms.saveConfig(JSON.stringify(newConfig));
|
|
setConfig(newConfig);
|
|
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 (
|
|
<HashRouter>
|
|
<Layout
|
|
currentTime={currentTime}
|
|
isHostConnected={isHostConnected}
|
|
robotTarget={robotTarget}
|
|
onTabChange={(tab) => {
|
|
setActiveTab(tab);
|
|
if (tab === null) setActiveIOTab('in'); // Reset IO tab when closing
|
|
}}
|
|
activeTab={activeTab}
|
|
isLoading={isLoading}
|
|
>
|
|
<Routes>
|
|
<Route
|
|
path="/"
|
|
element={
|
|
<HomePage
|
|
systemState={systemState}
|
|
currentRecipe={currentRecipe || { id: '0', name: 'No Recipe', lastModified: '-' }}
|
|
recipes={recipes}
|
|
robotTarget={robotTarget}
|
|
logs={logs}
|
|
ioPoints={ioPoints}
|
|
config={config}
|
|
isConfigRefreshing={isConfigRefreshing}
|
|
doorStates={doorStates}
|
|
isLowPressure={isLowPressure}
|
|
isEmergencyStop={isEmergencyStop}
|
|
activeTab={activeTab}
|
|
onSelectRecipe={handleSelectRecipe}
|
|
onMove={moveAxis}
|
|
onControl={handleControl}
|
|
onSaveConfig={handleSaveConfig}
|
|
onFetchConfig={fetchConfig}
|
|
onCloseTab={() => setActiveTab(null)}
|
|
videoRef={videoRef}
|
|
/>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/io-monitor"
|
|
element={
|
|
<IOMonitorPage
|
|
ioPoints={ioPoints}
|
|
onToggle={toggleIO}
|
|
activeIOTab={activeIOTab}
|
|
onIOTabChange={setActiveIOTab}
|
|
/>
|
|
}
|
|
/>
|
|
</Routes>
|
|
</Layout>
|
|
</HashRouter>
|
|
);
|
|
}
|