import React, { useState, useEffect, useCallback, useRef } from 'react'; import { ToolType, SimulationMap, AgvState, AgvMotionState, LogEntry, AgvError, AgvSignal, SystemFlag0, SystemFlag1, CSharpMapData, MapNode, MapEdge, FloorMark, MagnetLine, NodeType } from './types'; import { INITIAL_MAP, MAX_SPEED } from './constants'; import EditorToolbar from './components/EditorToolbar'; import SimulationCanvas from './components/SimulationCanvas'; import SerialConsole from './components/SerialConsole'; import AgvControls from './components/AgvControls'; import BmsPanel from './components/BmsPanel'; import AcsControls from './components/AcsControls'; import AgvStatusPanel from './components/AgvStatusPanel'; import AgvAutoRunControls from './components/AgvAutoRunControls'; import SystemLogPanel from './components/SystemLogPanel'; import { SerialPortHandler } from './services/serialService'; // --- ACS CRC16 Table Generation (Matches C# Logic) --- const ACS_CRC_TABLE = new Uint16Array(256); (function initAcsCrcTable() { const polynomial = 0x7979; for (let i = 0; i < 256; i++) { let value = 0; let temp = i; for (let j = 0; j < 8; j++) { if (((value ^ temp) & 0x0001) !== 0) { value = (value >> 1) ^ polynomial; } else { value >>= 1; } temp >>= 1; } ACS_CRC_TABLE[i] = value & 0xFFFF; } })(); const calculateAcsCrc16 = (data: number[] | Uint8Array): number => { let crc = 0xFFFF; for (let i = 0; i < data.length; i++) { const index = (crc ^ data[i]) & 0xFF; crc = (crc >> 8) ^ ACS_CRC_TABLE[index]; } return crc & 0xFFFF; }; const App: React.FC = () => { // --- State --- const [activeTool, setActiveTool] = useState(ToolType.SELECT); // Map Data const [mapData, setMapData] = useState(INITIAL_MAP); // AGV Logic State const [agvState, setAgvState] = useState({ x: 100, y: 100, rotation: 0, targetRotation: null, speed: MAX_SPEED, liftHeight: 0, motionState: AgvMotionState.IDLE, runConfig: { direction: 'FWD', branch: 'STRAIGHT', speedLevel: 'M' }, error: null, sensorStatus: '1', // Default per C# DevAGV.cs detectedRfid: null, sensorLineFront: false, sensorLineRear: false, sensorMark: false, // Lift & Magnet magnetOn: false, liftStatus: 'IDLE', lidarEnabled: true, // Default ON // Protocol Flags Initial State system0: 0, system1: (1 << SystemFlag1.agv_stop), // Default STOP state errorFlags: 0, signalFlags: 0, // Will be updated by useEffect batteryLevel: 95, maxCapacity: 100, // 100Ah batteryTemp: 25, cellVoltages: Array(8).fill(3.2), }); // Ref to hold latest state for Serial Callbacks (prevents stale closures) const agvStateRef = useRef(agvState); useEffect(() => { agvStateRef.current = agvState; }, [agvState]); // Logs const [logs, setLogs] = useState([]); // Auto-clear logic: Keep last 500 lines const addLog = useCallback((source: 'AGV' | 'BMS' | 'ACS' | 'SYSTEM', type: 'INFO' | 'RX' | 'TX' | 'ERROR', message: string) => { setLogs(prev => [...prev.slice(-499), { id: crypto.randomUUID(), timestamp: new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }), source, type, message }]); }, []); const clearLogs = useCallback((source: 'AGV' | 'BMS' | 'ACS' | 'SYSTEM') => { setLogs(prev => prev.filter(l => l.source !== source)); }, []); // Serial Handlers const agvSerialRef = useRef(null); const bmsSerialRef = useRef(null); const acsSerialRef = useRef(null); const [agvConnected, setAgvConnected] = useState(false); const [bmsConnected, setBmsConnected] = useState(false); const [acsConnected, setAcsConnected] = useState(false); const [agvPortInfo, setAgvPortInfo] = useState(null); const [bmsPortInfo, setBmsPortInfo] = useState(null); const [acsPortInfo, setAcsPortInfo] = useState(null); // Serial Configuration const [agvBaudRate, setAgvBaudRate] = useState(57600); const [bmsBaudRate, setBmsBaudRate] = useState(9600); const [acsBaudRate, setAcsBaudRate] = useState(9600); // --- Helpers to Set/Clear Bits --- const setAgvBit = (field: 'system0' | 'system1' | 'errorFlags' | 'signalFlags', bit: number, value: boolean) => { setAgvState(prev => { let current = prev[field]; if (value) current |= (1 << bit); else current &= ~(1 << bit); if (current === prev[field]) return prev; return { ...prev, [field]: current }; }); }; // --- AGV Protocol Parser --- const agvBufferRef = useRef([]); // Format: [STX]ACK[CMD]**[ETX] const sendAck = useCallback((commandToAck: string) => { if (agvSerialRef.current) { const encoder = new TextEncoder(); const cmdBytes = encoder.encode("ACK"); const valBytes = encoder.encode(commandToAck); const payload = new Uint8Array(1 + cmdBytes.length + valBytes.length + 2 + 1); payload[0] = 0x02; // STX payload.set(cmdBytes, 1); payload.set(valBytes, 1 + cmdBytes.length); payload[payload.length - 3] = 0x2A; // '*' payload[payload.length - 2] = 0x2A; // '*' payload[payload.length - 1] = 0x03; // ETX agvSerialRef.current.send(payload); addLog('AGV', 'TX', `ACK ${commandToAck}`); } }, [addLog]); // Tag Transmission Logic const sendTag = useCallback((tagId: string) => { if (agvSerialRef.current) { const paddedId = tagId.padStart(6, '0').slice(-6); const encoder = new TextEncoder(); const cmdBytes = encoder.encode("TAG"); const valBytes = encoder.encode(paddedId); const payload = new Uint8Array(1 + cmdBytes.length + valBytes.length + 2 + 1); let offset = 0; payload[offset++] = 0x02; // STX payload.set(cmdBytes, offset); offset += cmdBytes.length; payload.set(valBytes, offset); offset += valBytes.length; payload[offset++] = 0x2A; // '*' payload[offset++] = 0x2A; // '*' payload[offset++] = 0x03; // ETX agvSerialRef.current.send(payload); addLog('AGV', 'TX', `TAG ${paddedId}`); } }, [addLog]); const handleAgvData = useCallback((data: Uint8Array) => { for (let i = 0; i < data.length; i++) { agvBufferRef.current.push(data[i]); } const buf = agvBufferRef.current; while (true) { const stxIdx = buf.indexOf(0x02); if (stxIdx === -1) { agvBufferRef.current = []; return; } if (stxIdx > 0) { buf.splice(0, stxIdx); } const etxIdx = buf.indexOf(0x03); if (etxIdx === -1) { return; } const frameBytes = buf.splice(0, etxIdx + 1); processAgvFrame(frameBytes); } }, []); const processAgvFrame = (frame: number[]) => { if (frame.length < 7) return; const textDecoder = new TextDecoder(); const cmd = textDecoder.decode(new Uint8Array(frame.slice(1, 4))); const dataStr = textDecoder.decode(new Uint8Array(frame.slice(4, frame.length - 3))).trim(); addLog('AGV', 'RX', `${cmd} ${dataStr}`); switch (cmd) { case 'CRN': // Run Command (Auto Run) if (dataStr.length >= 4) { const dirChar = dataStr[0].toUpperCase(); const bunki = dataStr[1]; const speed = dataStr[2]; const sensor = dataStr[3]; const isBack = dirChar === 'B' || dirChar === 'R'; const direction = isBack ? 'BWD' : 'FWD'; setAgvState(s => { const newBranch = (bunki === 'L' ? 'LEFT' : bunki === 'R' ? 'RIGHT' : bunki === 'S' ? 'STRAIGHT' : s.runConfig.branch); const newSpeed = (['L', 'M', 'H'].includes(speed) ? speed : s.runConfig.speedLevel) as any; // Treat '0' as placeholder for sensor if we want to preserve CBR logic, // BUT allow manual toggle via UI to override later. // Here, we assume CRN sending '0' shouldn't turn off sensor if user set it via CBR. const newSensor = (sensor === '0') ? s.sensorStatus : sensor; const newLidar = newSensor !== '0'; return { ...s, sensorStatus: newSensor, lidarEnabled: newLidar, motionState: AgvMotionState.RUNNING, runConfig: { direction: direction, branch: newBranch, speedLevel: newSpeed } }; }); addLog('AGV', 'INFO', `Run: Dir=${direction} Branch=${bunki} Speed=${speed} Sensor=${sensor}`); } else { setAgvState(s => ({ ...s, motionState: AgvMotionState.RUNNING })); } // Update Flags setAgvBit('system1', SystemFlag1.agv_stop, false); setAgvBit('system1', SystemFlag1.agv_run, true); break; case 'CST': // Stop Command if (dataStr.length > 0 && dataStr.charAt(0).toUpperCase() === 'M') { // Mark Stop Command (Drive to next mark) setAgvState(s => ({ ...s, motionState: AgvMotionState.MARK_STOPPING, runConfig: { ...s.runConfig, speedLevel: 'L' // Force low speed for precision } })); // In Mark Stopping mode, AGV is still technically running until it hits the mark setAgvBit('system1', SystemFlag1.agv_stop, false); setAgvBit('system1', SystemFlag1.agv_run, true); addLog('AGV', 'INFO', 'CMD: Mark Stop Mode Started'); } else { // Normal Stop setAgvBit('system1', SystemFlag1.agv_run, false); setAgvBit('system1', SystemFlag1.agv_stop, true); setAgvState(s => ({ ...s, motionState: AgvMotionState.IDLE })); } break; case 'CBR': // Branch if (dataStr.length >= 3) { const dirChar = dataStr[0].toUpperCase(); const bunki = dataStr[1]; const speed = dataStr[2]; // Default to '1' (ON) if not provided, assuming CBR implies a valid path const sensor = dataStr.length > 3 ? dataStr[3] : '1'; const isBack = dirChar === 'B' || dirChar === 'R'; const direction = isBack ? 'BWD' : 'FWD'; setAgvState(s => { // Logic: Update if specific char provided, else keep existing const newBranch = (bunki === 'L' ? 'LEFT' : bunki === 'R' ? 'RIGHT' : bunki === 'S' ? 'STRAIGHT' : s.runConfig.branch); const newSpeed = (['L', 'M', 'H'].includes(speed) ? speed : s.runConfig.speedLevel) as any; const newLidar = sensor !== '0'; return { ...s, sensorStatus: sensor, lidarEnabled: newLidar, runConfig: { direction: direction, branch: newBranch, speedLevel: newSpeed } }; }); addLog('AGV', 'INFO', `Branch Set: Dir=${direction} Branch=${bunki} Speed=${speed} Sensor=${sensor}`); } break; case 'CRT': // Manual Control (Remote / Jog) const op = dataStr.charAt(0).toUpperCase(); let manualMotion = AgvMotionState.IDLE; let targetDir: 'FWD' | 'BWD' = 'FWD'; if (op === 'F') { manualMotion = AgvMotionState.FORWARD; targetDir = 'FWD'; } else if (op === 'B') { manualMotion = AgvMotionState.BACKWARD; targetDir = 'BWD'; } else if (op === 'L') manualMotion = AgvMotionState.TURN_LEFT; else if (op === 'R') manualMotion = AgvMotionState.TURN_RIGHT; else if (op === 'S') manualMotion = AgvMotionState.IDLE; setAgvState(s => ({ ...s, motionState: manualMotion, runConfig: { ...s.runConfig, direction: targetDir } })); const isMoving = manualMotion !== AgvMotionState.IDLE; setAgvBit('system1', SystemFlag1.agv_stop, !isMoving); setAgvBit('system1', SystemFlag1.agv_run, isMoving); sendAck(cmd); break; case 'SFR': // Reset Errors setAgvState(s => ({ ...s, error: null, errorFlags: 0 })); setAgvBit('system1', SystemFlag1.agv_run, false); setAgvBit('system1', SystemFlag1.agv_stop, true); break; case 'CBT': // Charging Command if (dataStr.length > 4) { const chargeCmd = dataStr[4]; const isCharging = chargeCmd === 'I'; setAgvBit('system1', SystemFlag1.Battery_charging, isCharging); } break; case 'CLF': // Lift / Magnet Control const subCmd = dataStr.trim().toUpperCase(); if (subCmd === 'UP0000') { setAgvState(s => ({ ...s, liftStatus: 'UP' })); } else if (subCmd === 'DN0000') { setAgvState(s => ({ ...s, liftStatus: 'DOWN' })); } else if (subCmd === 'STP000') { setAgvState(s => ({ ...s, liftStatus: 'IDLE' })); } else if (subCmd === 'ON0000') { setAgvState(s => ({ ...s, magnetOn: true })); } else if (subCmd === 'OFF000') { setAgvState(s => ({ ...s, magnetOn: false })); } sendAck(cmd); break; case 'CTL': // Turn Left 180 handleTurn180('LEFT'); sendAck(cmd); break; case 'CTR': // Turn Right 180 handleTurn180('RIGHT'); sendAck(cmd); break; case 'SSH': case 'SSM': case 'SSL': case 'SSS': case 'SHS': case 'SLS': case 'SPK': case 'SPM': case 'SPL': case 'SPS': case 'SIK': case 'SIM': case 'SIH': case 'SIL': case 'SIS': case 'SDK': case 'SDW': case 'SDL': case 'SDS': case 'SRS': case 'SCK': case 'SSK': case 'STT': case 'SSI': case 'SMD': case 'SSC': case 'SPN': case 'SPH': case 'SCH': case 'SDH': case 'SDM': case 'SLB': case 'SGS': sendAck(cmd); break; case 'ACK': break; } }; const handleTurn180 = (direction: 'LEFT' | 'RIGHT') => { setAgvState(s => ({ ...s, motionState: direction === 'LEFT' ? AgvMotionState.TURN_LEFT_180 : AgvMotionState.TURN_RIGHT_180, targetRotation: direction === 'LEFT' ? s.rotation - 180 : s.rotation + 180 })); }; const autoSendData = useCallback(() => { if (!agvConnected || !agvSerialRef.current || !agvSerialRef.current.connected) return; // Use ref to get latest state without recreating function const s = agvStateRef.current; const barr = new Uint8Array(34); barr[0] = 0x02; barr[1] = 0x53; barr[2] = 0x54; barr[3] = 0x53; const encodeHex = (val: number, len: number) => { const hex = val.toString(16).toUpperCase().padStart(len, '0'); return new TextEncoder().encode(hex); }; const voltBytes = new TextEncoder().encode("255"); barr.set(voltBytes, 4); barr.set(encodeHex(s.system0, 4), 7); barr.set(encodeHex(s.system1, 4), 11); barr.set(encodeHex(s.errorFlags, 4), 15); const spdChar = s.runConfig.speedLevel.charCodeAt(0); barr[19] = spdChar; const branchChar = s.runConfig.branch === 'LEFT' ? 'L'.charCodeAt(0) : s.runConfig.branch === 'RIGHT' ? 'R'.charCodeAt(0) : 'S'.charCodeAt(0); barr[20] = branchChar; const dirChar = s.runConfig.direction === 'FWD' ? 'F'.charCodeAt(0) : 'B'.charCodeAt(0); barr[21] = dirChar; barr[22] = s.sensorStatus.charCodeAt(0); barr.set(encodeHex(s.signalFlags, 2), 23); barr[31] = '*'.charCodeAt(0); barr[32] = '*'.charCodeAt(0); barr[33] = 0x03; agvSerialRef.current.send(barr); // Log Transmission (Throttled log could be nice, but simple for now) addLog('AGV', 'TX', `STS (AutoSend)`); }, [agvConnected, addLog]); useEffect(() => { if (agvConnected) { const timer = setInterval(() => { autoSendData(); }, 500); return () => clearInterval(timer); } }, [autoSendData, agvConnected]); // --- Main Logic: Sensors, Signals & Lift Physics --- useEffect(() => { // 1. Calculate Signals let sig = 0; if (agvState.sensorMark) sig |= (1 << AgvSignal.mark_sensor_1); // Front Line Sensor & Gate Out if (!agvState.sensorLineFront) { // Gate Out: Active when line is NOT detected sig |= (1 << AgvSignal.front_gate_out); } // Rear Sensor Gate Out if (!agvState.sensorLineRear) { // Gate Out: Active when line is NOT detected sig |= (1 << AgvSignal.rear_sensor_out); } // Lift Sensors if (agvState.liftHeight <= 0) sig |= (1 << AgvSignal.lift_down_sensor); if (agvState.liftHeight >= 100) sig |= (1 << AgvSignal.lift_up_sensor); // Magnet Relay if (agvState.magnetOn) sig |= (1 << AgvSignal.magnet_relay); // 2. Calculate Error Flags let err = agvState.errorFlags; if (agvState.error === 'LINE_OUT') err |= (1 << AgvError.line_out_error); else err &= ~(1 << AgvError.line_out_error); // 3. Calculate System Flags let sys1 = agvState.system1; const isAutoRun = agvState.motionState === AgvMotionState.RUNNING || agvState.motionState === AgvMotionState.MARK_STOPPING; const isManualRun = [AgvMotionState.FORWARD, AgvMotionState.BACKWARD, AgvMotionState.TURN_LEFT, AgvMotionState.TURN_RIGHT, AgvMotionState.TURN_LEFT_180, AgvMotionState.TURN_RIGHT_180].includes(agvState.motionState); if (isAutoRun) { sys1 |= (1 << SystemFlag1.agv_run); sys1 &= ~(1 << SystemFlag1.agv_stop); } else if (isManualRun) { // Manual move: Ensure stop bit is OFF. Run bit can be ON to indicate active state (matches CRT logic) sys1 |= (1 << SystemFlag1.agv_run); sys1 &= ~(1 << SystemFlag1.agv_stop); } else if (agvState.motionState === AgvMotionState.IDLE) { sys1 &= ~(1 << SystemFlag1.agv_run); sys1 |= (1 << SystemFlag1.agv_stop); } if (sig !== agvState.signalFlags || err !== agvState.errorFlags || sys1 !== agvState.system1) { setAgvState(s => ({ ...s, signalFlags: sig, errorFlags: err, system1: sys1 })); } }, [ agvState.sensorMark, agvState.sensorLineFront, agvState.sensorLineRear, agvState.error, agvState.motionState, agvState.signalFlags, agvState.errorFlags, agvState.system1, agvState.detectedRfid, agvState.liftHeight, agvState.magnetOn ]); // --- Lift Animation Physics Loop --- useEffect(() => { let animId: number; const animateLift = () => { setAgvState(prev => { if (prev.liftStatus === 'IDLE') return prev; let newHeight = prev.liftHeight; const step = 0.6; // Speed of lift (~3s for 0-100) if (prev.liftStatus === 'UP') { newHeight += step; if (newHeight >= 100) { newHeight = 100; return { ...prev, liftHeight: 100, liftStatus: 'IDLE' }; } } else if (prev.liftStatus === 'DOWN') { newHeight -= step; if (newHeight <= 0) { newHeight = 0; return { ...prev, liftHeight: 0, liftStatus: 'IDLE' }; } } return { ...prev, liftHeight: newHeight }; }); animId = requestAnimationFrame(animateLift); }; animId = requestAnimationFrame(animateLift); return () => cancelAnimationFrame(animId); }, []); // Logic relies on functional state updates, so empty dependency is safe-ish, but let's be cleaner. // Actually, requestAnimationFrame runs continuously. We just need to ensure we don't leak. // --- BMS Protocol Logic --- const bmsBufferRef = useRef([]); const handleBmsData = useCallback((data: Uint8Array) => { // Log raw data for debugging visibility const hex = Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); addLog('BMS', 'RX', `RAW: ${hex}`); for (let i = 0; i < data.length; i++) { bmsBufferRef.current.push(data[i]); } const buf = bmsBufferRef.current; // Protocol: 7 bytes total. Start=0xDD, End=0x77. while (buf.length >= 7) { const startIdx = buf.indexOf(0xDD); if (startIdx === -1) { bmsBufferRef.current = []; return; } if (startIdx > 0) { buf.splice(0, startIdx); } // Wait for end byte (not fully safe if 77 is inside payload, but standard for this packet size) // Actually, fixed length is 34 for Status, 23 for Cells. // But we receive potentially fragmented. // Let's rely on fixed length per known command if we had one. // Here we just look for 0x77. const endIdx = buf.indexOf(0x77); if (endIdx === -1) return; const packet = buf.splice(0, endIdx + 1); processBmsPacket(packet); } }, [addLog]); const processBmsPacket = (packet: number[]) => { const cmd = packet[2]; // const hexStr = packet.map(b => b.toString(16).padStart(2,'0').toUpperCase()).join(' '); if (cmd === 0x03) { sendBmsStatus(); } else if (cmd === 0x04) { sendBmsCellVoltage(); } }; const sendBmsStatus = () => { if (!bmsSerialRef.current) return; const s = agvStateRef.current; // Use Ref for latest state const resp = new Uint8Array(34); resp[0] = 0xDD; resp[1] = 0x03; resp[2] = 0x00; resp[3] = 0x1b; //arrary 3value 0->1b const totalV = Math.floor(s.cellVoltages.reduce((a, b) => a + b, 0) * 100); resp[4] = (totalV >> 8) & 0xFF; resp[5] = totalV & 0xFF; const remainAh = Math.floor(s.maxCapacity * (s.batteryLevel / 100)); resp[8] = (remainAh >> 8) & 0xFF; resp[9] = remainAh & 0xFF; const maxAh = s.maxCapacity; resp[10] = (maxAh >> 8) & 0xFF; resp[11] = maxAh & 0xFF; resp[23] = Math.floor(s.batteryLevel); const t1 = Math.floor((s.batteryTemp * 10) + 2731); const t2 = Math.floor((s.batteryTemp * 10) + 2731); resp[27] = (t1 >> 8) & 0xFF; resp[28] = t1 & 0xFF; resp[29] = (t2 >> 8) & 0xFF; resp[30] = t2 & 0xFF; resp[31] = 0x00; //*magic numbee(skip) resp[32] = 0x00; //*magic numbee(skip) resp[33] = 0x77; bmsSerialRef.current.send(resp); addLog('BMS', 'TX', 'DD ... 77 (Status)'); }; const sendBmsCellVoltage = () => { if (!bmsSerialRef.current) return; const s = agvStateRef.current; // Use Ref for latest state const resp = new Uint8Array(23); resp[0] = 0xDD; resp[1] = 0x04; resp[2] = 0x00; resp[3] = 0x10; let checksumSum = resp[3]; for (let i = 0; i < 8; i++) { const v = Math.floor((s.cellVoltages[i] || 0) * 1000); const idx = 4 + (i * 2); const high = (v >> 8) & 0xFF; const low = v & 0xFF; resp[idx] = high; resp[idx + 1] = low; checksumSum += high + low; } const checksum = (0xFFFF - checksumSum + 1) & 0xFFFF; resp[20] = (checksum >> 8) & 0xFF; resp[21] = checksum & 0xFF; resp[22] = 0x77; bmsSerialRef.current.send(resp); addLog('BMS', 'TX', 'DD ... 77 (Cells)'); }; // --- ACS Protocol Logic (Implementing C# Logic) --- const acsBufferRef = useRef([]); const acsParseErrorsRef = useRef(0); const sendAcsPacket = useCallback((cmd: number, payload: number[] = []) => { if (!acsSerialRef.current) { addLog('ACS', 'ERROR', 'Not Connected'); return; } const id = 1; // Default AGV ID const len = 1 + 1 + payload.length; // ID + CMD + DATA const buffer = [0x02, len, id, cmd, ...payload]; // Calculate CRC over [LEN, ID, CMD, DATA] (indices 1 to end) const crcData = buffer.slice(1); const crc = calculateAcsCrc16(crcData); buffer.push(crc & 0xFF); // Low Byte buffer.push((crc >> 8) & 0xFF); // High Byte buffer.push(0x03); // ETX acsSerialRef.current.send(new Uint8Array(buffer)); const hex = buffer.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); addLog('ACS', 'TX', `CMD:${cmd.toString(16).toUpperCase()} [${hex}]`); }, [addLog]); const handleAcsData = useCallback((data: Uint8Array) => { for (let i = 0; i < data.length; i++) { acsBufferRef.current.push(data[i]); } const buf = acsBufferRef.current; while (buf.length > 0) { // 1. Find STX const stxIndex = buf.indexOf(0x02); // STX if (stxIndex === -1) { buf.length = 0; // Clear junk return; } // Remove garbage before STX if (stxIndex > 0) { buf.splice(0, stxIndex); } // 2. Check minimal size (STX + Len + ID + Cmd + CRC + ETX) = 7 bytes if data is 0? // Per C#: Length = 1(ID)+1(Cmd)+DataLen. // Packet: STX(1) + Len(1) + Payload(Len) + CRC(2) + ETX(1) = 5 + Len. if (buf.length < 2) return; // Need Length byte const lengthByte = buf[1]; const totalPacketSize = lengthByte + 5; if (buf.length < totalPacketSize) return; // Wait for more data // 3. Extract and Verify const packetData = buf.slice(0, totalPacketSize); // Verify ETX if (packetData[totalPacketSize - 1] !== 0x03) { addLog('ACS', 'ERROR', 'ETX Missing'); buf.shift(); // Invalid packet, shift 1 and retry continue; } // Verify CRC // CRC covers: [Length, ID, Command, Data...] (Skip STX, Exclude CRC bytes) // Bytes to CRC: From index 1 to (1 + length) inclusive? // C# Logic: CalculateCRC16(packetData.Skip(1).Take(length + 1)) // packetData indices: 0=STX, 1=Len, 2=ID, 3=Cmd... // We need indices 1 to (1 + length) inclusive. // Total size is length + 5. // CRC is at [total - 3, total - 2]. const dataForCrc = packetData.slice(1, lengthByte + 2); // slice end is exclusive const calcCrc = calculateAcsCrc16(dataForCrc); const receivedCrc = (packetData[totalPacketSize - 2] << 8) | packetData[totalPacketSize - 3]; // Little Endian in C# BitConverter? // C# BitConverter.ToUInt16(rawData, rawData.Length - 3). // rawData ends with [CRC_L, CRC_H, ETX] usually? // Default Little Endian. So index len-3 is Low, len-2 is High. if (receivedCrc !== 0xFFFF && calcCrc !== receivedCrc) { const hex = packetData.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); addLog('ACS', 'ERROR', `CRC Fail: ${hex}`); acsParseErrorsRef.current++; if (acsParseErrorsRef.current > 3) buf.length = 0; // Reset on too many errors else buf.shift(); continue; } acsParseErrorsRef.current = 0; // Parse Success const id = packetData[2]; const cmd = packetData[3]; const payload = packetData.slice(4, totalPacketSize - 3); const hexStr = packetData.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); addLog('ACS', 'RX', `[ID:${id} CMD:${cmd}] ${hexStr}`); // Consume buffer buf.splice(0, totalPacketSize); } }, [addLog]); // --- Connect/Disconnect Handlers --- const connectAgv = useCallback(async () => { if (!agvSerialRef.current) { agvSerialRef.current = new SerialPortHandler(handleAgvData); } try { await agvSerialRef.current.connect(agvBaudRate); setAgvConnected(true); setAgvPortInfo(agvSerialRef.current.getPortInfo()); addLog('AGV', 'INFO', `Connected at ${agvBaudRate} baud`); } catch (err: any) { if (err.message !== 'USER_CANCELLED') { console.error(err); addLog('AGV', 'ERROR', `Connection failed: ${err.message}`); } } }, [agvBaudRate, handleAgvData, addLog]); const disconnectAgv = useCallback(async () => { if (agvSerialRef.current) { await agvSerialRef.current.disconnect(); setAgvConnected(false); setAgvPortInfo(null); addLog('AGV', 'INFO', 'Disconnected'); } }, [addLog]); const connectBms = useCallback(async () => { if (!bmsSerialRef.current) { bmsSerialRef.current = new SerialPortHandler(handleBmsData); } try { await bmsSerialRef.current.connect(bmsBaudRate); setBmsConnected(true); setBmsPortInfo(bmsSerialRef.current.getPortInfo()); addLog('BMS', 'INFO', `Connected at ${bmsBaudRate} baud`); } catch (err: any) { if (err.message !== 'USER_CANCELLED') { console.error(err); addLog('BMS', 'ERROR', `Connection failed: ${err.message}`); } } }, [bmsBaudRate, handleBmsData, addLog]); const disconnectBms = useCallback(async () => { if (bmsSerialRef.current) { await bmsSerialRef.current.disconnect(); setBmsConnected(false); setBmsPortInfo(null); addLog('BMS', 'INFO', 'Disconnected'); } }, [addLog]); const connectAcs = useCallback(async () => { if (!acsSerialRef.current) { acsSerialRef.current = new SerialPortHandler(handleAcsData); } try { await acsSerialRef.current.connect(acsBaudRate); setAcsConnected(true); setAcsPortInfo(acsSerialRef.current.getPortInfo()); addLog('ACS', 'INFO', `Connected at ${acsBaudRate} baud`); } catch (err: any) { if (err.message !== 'USER_CANCELLED') { console.error(err); addLog('ACS', 'ERROR', `Connection failed: ${err.message}`); } } }, [acsBaudRate, handleAcsData, addLog]); const disconnectAcs = useCallback(async () => { if (acsSerialRef.current) { await acsSerialRef.current.disconnect(); setAcsConnected(false); setAcsPortInfo(null); addLog('ACS', 'INFO', 'Disconnected'); } }, [addLog]); // --- Map Loading Logic (Updated for New Structure) --- const loadMapFromCSharp = useCallback((csharpData: CSharpMapData) => { const newNodes: MapNode[] = []; const newEdges: MapEdge[] = []; const edgeSet = new Set(); // 1. Process Nodes if (csharpData.Nodes && Array.isArray(csharpData.Nodes)) { csharpData.Nodes.forEach((n) => { const [xStr, yStr] = n.Position.split(',').map(s => s.trim()); const x = parseFloat(xStr) || 0; const y = parseFloat(yStr) || 0; // Map StationType to Internal NodeType let internalType = n.StationType; if (n.StationType === 2) internalType = NodeType.Charging; else if (n.StationType === 3) internalType = NodeType.UnLoader; newNodes.push({ id: n.Id, x, y, type: internalType, name: n.Text || n.AliasName || "", rfidId: n.RfidId, connectedNodes: n.ConnectedNodes || [], foreColor: n.NodeTextForeColor, fontSize: n.NodeTextFontSize }); if (n.ConnectedNodes && Array.isArray(n.ConnectedNodes)) { n.ConnectedNodes.forEach((targetId) => { const ids = [n.Id, targetId].sort(); const key = ids.join('-'); if (!edgeSet.has(key)) { edgeSet.add(key); newEdges.push({ id: `edge_${key}`, from: ids[0], to: ids[1] }); } }); } }); } // 2. Process Labels (Convert to Nodes Type=Label) if (csharpData.Labels && Array.isArray(csharpData.Labels)) { csharpData.Labels.forEach(l => { const [xStr, yStr] = l.Position.split(',').map(s => s.trim()); newNodes.push({ id: l.Id, x: parseFloat(xStr) || 0, y: parseFloat(yStr) || 0, type: NodeType.Label, name: "", rfidId: "", connectedNodes: [], labelText: l.Text, foreColor: l.ForeColor, backColor: l.BackColor, fontSize: l.FontSize }); }); } // 3. Process Images (Convert to Nodes Type=Image) if (csharpData.Images && Array.isArray(csharpData.Images)) { csharpData.Images.forEach(img => { const [xStr, yStr] = img.Position.split(',').map(s => s.trim()); newNodes.push({ id: img.Id, x: parseFloat(xStr) || 0, y: parseFloat(yStr) || 0, type: NodeType.Image, name: img.Name, rfidId: "", connectedNodes: [], imageBase64: img.ImageBase64 }); }); } // 4. Process Magnets const newMagnets: MagnetLine[] = []; if (csharpData.Magnets && Array.isArray(csharpData.Magnets)) { csharpData.Magnets.forEach(m => { const type = m.ControlPoint ? 'CURVE' : 'STRAIGHT'; newMagnets.push({ id: m.Id, type: type, p1: { x: m.P1.X, y: m.P1.Y }, p2: { x: m.P2.X, y: m.P2.Y }, controlPoint: m.ControlPoint ? { x: m.ControlPoint.X, y: m.ControlPoint.Y } : undefined }); }); } // 5. Process Marks const newMarks: FloorMark[] = []; if (csharpData.Marks && Array.isArray(csharpData.Marks)) { csharpData.Marks.forEach(mk => { newMarks.push({ id: mk.Id, x: mk.X, y: mk.Y, rotation: mk.Rotation }); }); } setMapData({ nodes: newNodes, edges: newEdges, marks: newMarks, magnets: newMagnets }); addLog('SYSTEM', 'INFO', `New Map loaded: ${newNodes.length} objects`); }, [addLog]); // --- Initialize Map from NewMap.json on Mount --- useEffect(() => { fetch('NewMap.json') .then(response => { if (!response.ok) throw new Error("Default map not found"); return response.json(); }) .then((data: CSharpMapData) => { loadMapFromCSharp(data); }) .catch(err => { console.log("Could not load default map:", err); }); }, [loadMapFromCSharp]); // --- Map IO Logic --- const processMapFile = (file: File) => { const reader = new FileReader(); reader.onload = (evt) => { try { const jsonStr = evt.target?.result as string; const csharpData = JSON.parse(jsonStr) as CSharpMapData; loadMapFromCSharp(csharpData); } catch (err) { console.error(err); alert('Failed to parse map file.'); } }; reader.readAsText(file); }; const saveMap = () => { const outNodes: any[] = []; const outLabels: any[] = []; const outImages: any[] = []; mapData.nodes.forEach(n => { const pos = `${Math.round(n.x)}, ${Math.round(n.y)}`; if (n.type === NodeType.Label) { outLabels.push({ Id: n.id, Type: 1, Text: n.labelText || "Label", Position: pos, ForeColor: n.foreColor || "White", BackColor: n.backColor || "Transparent", FontFamily: "Arial", FontSize: n.fontSize || 12, FontStyle: 0, Padding: 5 }); } else if (n.type === NodeType.Image) { outImages.push({ Id: n.id, Type: 2, Name: n.name || "Image", Position: pos, ImagePath: "", ImageBase64: n.imageBase64 || "", Scale: "1, 1", Opacity: 1.0, Rotation: 0.0 }); } else { // Standard Node // Map Internal NodeType back to StationType let stType = n.type; if (n.type === NodeType.Charging) stType = 2; // Cleaner else if (n.type === NodeType.UnLoader) stType = 3; // Unloader // Re-calculate connected nodes const connected = new Set(); mapData.edges.forEach(e => { if (e.from === n.id) connected.add(e.to); if (e.to === n.id) connected.add(e.from); }); outNodes.push({ Id: n.id, Text: n.name || "", Position: pos, Type: 0, // Always 0 for nodes StationType: stType, ConnectedNodes: Array.from(connected), RfidId: n.rfidId || "", NodeTextForeColor: n.foreColor, NodeTextFontSize: n.fontSize || 7.0, AliasName: "", SpeedLimit: 0, CanDocking: [1, 2, 3, 4, 5].includes(n.type), DockDirection: n.type === NodeType.Charging || n.type === NodeType.ChargerStation ? 1 : 0, CanTurnLeft: true, CanTurnRight: true, DisableCross: false, IsActive: true }); } }); const outMagnets = mapData.magnets.map(m => ({ Id: m.id, Type: 4, P1: { X: m.p1.x, Y: m.p1.y }, P2: { X: m.p2.x, Y: m.p2.y }, ControlPoint: m.controlPoint ? { X: m.controlPoint.x, Y: m.controlPoint.y } : null })); const outMarks = mapData.marks.map(mk => ({ Id: mk.id, Type: 3, Position: `${Math.round(mk.x)}, ${Math.round(mk.y)}`, X: mk.x, Y: mk.y, Rotation: mk.rotation })); const exportData = { Nodes: outNodes, Labels: outLabels, Images: outImages, Magnets: outMagnets, Marks: outMarks, Settings: { BackgroundColorArgb: -14671840, ShowGrid: false }, CreatedDate: new Date().toISOString(), Version: "1.3" }; const json = JSON.stringify(exportData, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'NewMap.json'; a.click(); }; const loadMap = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { processMapFile(file); } }; input.click(); }; // --- Drag and Drop Handlers --- const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer.files[0]; if (file && file.name.endsWith('.json')) { processMapFile(file); } }; // --- Real-time updates to Serial (AGV Events) --- const prevSensors = useRef({ rfid: null as string | null, mark: false, line: false }); useEffect(() => { if (!agvConnected || !agvSerialRef.current) return; // Check RFID change if (agvState.detectedRfid !== prevSensors.current.rfid) { if (agvState.detectedRfid) { sendTag(agvState.detectedRfid); } } prevSensors.current = { rfid: agvState.detectedRfid, mark: agvState.sensorMark, line: agvState.sensorLineFront }; }, [agvState, agvConnected, sendTag]); return (
{/* 1. Far Left: Toolbar */}
{/* 2. Inner Left: Protocol Flags (Separated Sidebar) - Removed BMS from here */}
{/* Auto Run Controls (Left Sidebar) */}
{ if (agvState.error) return; setAgvState(s => ({ ...s, runConfig: { ...s.runConfig, [key]: value } })); }} toggleRun={() => { if (agvState.error) return; if (agvState.motionState === AgvMotionState.RUNNING || agvState.motionState === AgvMotionState.MARK_STOPPING) { setAgvState(s => ({ ...s, motionState: AgvMotionState.IDLE })); } else { const isFwd = agvState.runConfig.direction === 'FWD'; const hasLine = isFwd ? agvState.sensorLineFront : agvState.sensorLineRear; if (!hasLine) { setAgvState(s => ({ ...s, error: 'LINE_OUT' })); return; } setAgvState(s => ({ ...s, motionState: AgvMotionState.RUNNING })); } }} isRunning={agvState.motionState === AgvMotionState.RUNNING || agvState.motionState === AgvMotionState.MARK_STOPPING} isError={agvState.error !== null} setLidar={(isOn) => setAgvState(s => ({ ...s, lidarEnabled: isOn, sensorStatus: isOn ? '1' : '0' }))} />
{/* Middle 2: ACS Panel (Fixed) */}
{/* 3. Center: Canvas & Bottom Panels */}
addLog('SYSTEM', 'INFO', msg)} />
{/* Fixed Communication Panels (Now inside center column) */}
l.source === 'AGV')} isConnected={agvConnected} onConnect={connectAgv} onDisconnect={disconnectAgv} onClear={() => clearLogs('AGV')} baudRate={agvBaudRate} setBaudRate={setAgvBaudRate} portInfo={agvPortInfo} colorClass="text-blue-400" />
l.source === 'BMS')} isConnected={bmsConnected} onConnect={connectBms} onDisconnect={disconnectBms} onClear={() => clearLogs('BMS')} baudRate={bmsBaudRate} setBaudRate={setBmsBaudRate} portInfo={bmsPortInfo} colorClass="text-yellow-400" />
l.source === 'ACS')} isConnected={acsConnected} onConnect={connectAcs} onDisconnect={disconnectAcs} onClear={() => clearLogs('ACS')} baudRate={acsBaudRate} setBaudRate={setAcsBaudRate} portInfo={acsPortInfo} colorClass="text-green-400" />
{/* 4. Right: Controls & BMS & ACS */}
{/* Top: Controls (Scrollable if needed) */}
setAgvState(s => ({ ...s, motionState: m }))} setLift={(h) => setAgvState(s => ({ ...s, liftHeight: h }))} setRunConfig={(c) => setAgvState(s => ({ ...s, runConfig: c }))} setError={(e) => setAgvState(s => ({ ...s, error: e }))} onTurn180={handleTurn180} setMagnet={(isOn) => setAgvState(s => ({ ...s, magnetOn: isOn }))} setLiftStatus={(status) => setAgvState(s => ({ ...s, liftStatus: status }))} setLidar={(isOn) => setAgvState(s => ({ ...s, lidarEnabled: isOn, sensorStatus: isOn ? '1' : '0' }))} />
{/* Middle 1: BMS Panel (Fixed) */}
setAgvState(s => ({ ...s, batteryLevel: l }))} />
{/* Bottom: System Logs (Fixed Height) */}
l.source === 'SYSTEM')} onClear={() => clearLogs('SYSTEM')} />
); }; export default App;