1362 lines
53 KiB
TypeScript
1362 lines
53 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: false,
|
|
isCharging: false, // Initial state
|
|
|
|
// 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,
|
|
batteryTemps: [25.0, 26.5],
|
|
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;
|
|
|
|
// 6, 7: Current (mA)
|
|
// Power (W) = (mA / 1000) * V => mA = (W / V) * 1000.
|
|
// Discharging: ~50W, Charging: ~-450W.
|
|
const isCharging = s.isCharging; // Use manual state
|
|
const powerW = isCharging ? -450 : 50;
|
|
|
|
let currentMA = 0;
|
|
if (totalV > 0) {
|
|
const realV = totalV / 100; // Convert 0.01V to Volts
|
|
currentMA = Math.floor((powerW / totalV) * 10);
|
|
}
|
|
|
|
// Write Int16 (Little Endian or Big Endian? BMS usually Big Endian for these usually?
|
|
// Code above uses Big Endian for Voltage (resp[4] = high, resp[5] = low).
|
|
// Standard BMS protocols often BE. I will follow the pattern of Voltage.
|
|
// Handle negative numbers via Two's Complement for manual byte splitting if needed,
|
|
// but bitwise ops on standard JS numbers (treated as 32bit int) work fine for & 0xFF.
|
|
resp[6] = (currentMA >> 8) & 0xFF;
|
|
resp[7] = currentMA & 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.batteryTemps[0] * 10) + 2731);
|
|
const t2 = Math.floor((s.batteryTemps[1] * 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]);
|
|
|
|
// BMS Simulation
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setAgvState(s => {
|
|
const isCharging = s.isCharging;
|
|
let newLevel = s.batteryLevel;
|
|
|
|
if (isCharging) {
|
|
// Charge: 1% per min (faster than discharge)
|
|
newLevel = Math.min(100, s.batteryLevel + (1.0 / 60));
|
|
} else {
|
|
// Discharge: 5% per 10 min = 0.5% per min = 0.5 / 60 per sec
|
|
newLevel = Math.max(0, s.batteryLevel - (0.5 / 60));
|
|
}
|
|
|
|
// Fluctuate Voltages (+- 0.01V)
|
|
const newVoltages = s.cellVoltages.map(v => {
|
|
const delta = (Math.random() - 0.5) * 0.02;
|
|
let val = v + delta;
|
|
if (val < 2.8) val = 2.8;
|
|
if (val > 3.6) val = 3.6;
|
|
return val;
|
|
});
|
|
|
|
return {
|
|
...s,
|
|
batteryLevel: newLevel,
|
|
cellVoltages: newVoltages
|
|
};
|
|
});
|
|
}, 1000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
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-60 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-60 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-72 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 }))}
|
|
setIsCharging={(isConnected) => {
|
|
setAgvState(s => ({ ...s, isCharging: isConnected }));
|
|
}}
|
|
/>
|
|
</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; |