Files
AGVEmulator/App.tsx

1301 lines
51 KiB
TypeScript

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>(ToolType.SELECT);
// Map Data
const [mapData, setMapData] = useState<SimulationMap>(INITIAL_MAP);
// AGV Logic State
const [agvState, setAgvState] = useState<AgvState>({
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>(agvState);
useEffect(() => {
agvStateRef.current = agvState;
}, [agvState]);
// Logs
const [logs, setLogs] = useState<LogEntry[]>([]);
// 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<SerialPortHandler | null>(null);
const bmsSerialRef = useRef<SerialPortHandler | null>(null);
const acsSerialRef = useRef<SerialPortHandler | null>(null);
const [agvConnected, setAgvConnected] = useState(false);
const [bmsConnected, setBmsConnected] = useState(false);
const [acsConnected, setAcsConnected] = useState(false);
const [agvPortInfo, setAgvPortInfo] = useState<string | null>(null);
const [bmsPortInfo, setBmsPortInfo] = useState<string | null>(null);
const [acsPortInfo, setAcsPortInfo] = useState<string | null>(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<number[]>([]);
// 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<number[]>([]);
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<number[]>([]);
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<string>();
// 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<string>();
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 (
<div
className="flex h-screen w-screen bg-gray-950 text-white overflow-hidden"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 1. Far Left: Toolbar */}
<div className="w-16 p-2 border-r border-gray-800 flex flex-col items-center shrink-0">
<EditorToolbar
activeTool={activeTool}
setTool={setActiveTool}
onSave={saveMap}
onLoad={loadMap}
/>
</div>
{/* 2. Inner Left: Protocol Flags (Separated Sidebar) - Removed BMS from here */}
<div className="w-80 border-r border-gray-800 bg-gray-900 flex flex-col shrink-0">
<div className="flex-1 overflow-hidden">
<AgvStatusPanel agvState={agvState} />
</div>
{/* Auto Run Controls (Left Sidebar) */}
<div className="w-80 border-r border-gray-800 bg-gray-900 flex flex-col shrink-0">
<div className="flex-1 overflow-hidden">
<AgvAutoRunControls
agvState={agvState}
updateRunConfig={(key, value) => {
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' }))}
/>
</div>
</div>
{/* Middle 2: ACS Panel (Fixed) */}
<div className="flex-shrink-0">
<AcsControls onSend={sendAcsPacket} isConnected={acsConnected} mapData={mapData} />
</div>
</div>
{/* 3. Center: Canvas & Bottom Panels */}
<div className="flex-1 flex flex-col min-w-0 bg-gray-950 relative">
<div className="flex-1 relative overflow-hidden">
<SimulationCanvas
activeTool={activeTool}
mapData={mapData}
setMapData={setMapData}
agvState={agvState}
setAgvState={setAgvState}
onLog={(msg) => addLog('SYSTEM', 'INFO', msg)}
/>
</div>
{/* Fixed Communication Panels (Now inside center column) */}
<div className="h-64 border-t border-gray-700 bg-black grid grid-cols-3 gap-0 shrink-0">
<div className="border-r border-gray-800 h-full min-h-0">
<SerialConsole
title="AGV"
logs={logs.filter(l => l.source === 'AGV')}
isConnected={agvConnected}
onConnect={connectAgv}
onDisconnect={disconnectAgv}
onClear={() => clearLogs('AGV')}
baudRate={agvBaudRate}
setBaudRate={setAgvBaudRate}
portInfo={agvPortInfo}
colorClass="text-blue-400"
/>
</div>
<div className="border-r border-gray-800 h-full min-h-0">
<SerialConsole
title="BMS"
logs={logs.filter(l => l.source === 'BMS')}
isConnected={bmsConnected}
onConnect={connectBms}
onDisconnect={disconnectBms}
onClear={() => clearLogs('BMS')}
baudRate={bmsBaudRate}
setBaudRate={setBmsBaudRate}
portInfo={bmsPortInfo}
colorClass="text-yellow-400"
/>
</div>
<div className="h-full min-h-0">
<SerialConsole
title="ACS"
logs={logs.filter(l => l.source === 'ACS')}
isConnected={acsConnected}
onConnect={connectAcs}
onDisconnect={disconnectAcs}
onClear={() => clearLogs('ACS')}
baudRate={acsBaudRate}
setBaudRate={setAcsBaudRate}
portInfo={acsPortInfo}
colorClass="text-green-400"
/>
</div>
</div>
</div>
{/* 4. Right: Controls & BMS & ACS */}
<div className="w-80 border-l border-gray-800 bg-gray-900 flex flex-col shrink-0">
{/* Top: Controls (Scrollable if needed) */}
<div className="flex-1 overflow-y-auto">
<AgvControls
agvState={agvState}
setMotion={(m) => 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' }))}
/>
</div>
{/* Middle 1: BMS Panel (Fixed) */}
<div className="flex-shrink-0">
<BmsPanel state={agvState} setBatteryLevel={(l) => setAgvState(s => ({ ...s, batteryLevel: l }))} />
</div>
{/* Bottom: System Logs (Fixed Height) */}
<div className="h-48 flex-shrink-0">
<SystemLogPanel logs={logs.filter(l => l.source === 'SYSTEM')} onClear={() => clearLogs('SYSTEM')} />
</div>
</div>
</div >
);
};
export default App;