Initial commit: Refactor AgvAutoRunControls
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1219
NewMap.json
Normal file
1219
NewMap.json
Normal file
File diff suppressed because it is too large
Load Diff
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/drive/1vRAJl8k2mFhnuVvPiIQYjCVuYdZTmyiL
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
142
components/AcsControls.tsx
Normal file
142
components/AcsControls.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Navigation, Package, Zap, ChevronRight, Hash, Type } from 'lucide-react';
|
||||||
|
import { SimulationMap } from '../types';
|
||||||
|
|
||||||
|
interface AcsControlsProps {
|
||||||
|
onSend: (cmd: number, data?: number[]) => void;
|
||||||
|
isConnected: boolean;
|
||||||
|
mapData: SimulationMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AcsControls: React.FC<AcsControlsProps> = ({ onSend, isConnected, mapData }) => {
|
||||||
|
const [tagId, setTagId] = useState('0001');
|
||||||
|
const [alias, setAlias] = useState('charger1');
|
||||||
|
|
||||||
|
const PRESET_ALIASES = [
|
||||||
|
'charger1', 'charger2',
|
||||||
|
'loader', 'unloader', 'cleaner',
|
||||||
|
'buffer1', 'buffer2', 'buffer3', 'buffer4', 'buffer5', 'buffer6'
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableTags = useMemo(() => {
|
||||||
|
// Extract unique, non-empty RFID tags from map nodes
|
||||||
|
const tags = mapData.nodes
|
||||||
|
.filter(n => n.rfidId && n.rfidId.trim() !== '')
|
||||||
|
.map(n => n.rfidId);
|
||||||
|
return Array.from(new Set(tags)).sort();
|
||||||
|
}, [mapData]);
|
||||||
|
|
||||||
|
const handleSend = (cmd: number, dataStr?: string, dataByte?: number) => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
|
let payload: number[] = [];
|
||||||
|
|
||||||
|
if (dataStr !== undefined) {
|
||||||
|
// Convert string to ASCII bytes
|
||||||
|
for (let i = 0; i < dataStr.length; i++) {
|
||||||
|
payload.push(dataStr.charCodeAt(i));
|
||||||
|
}
|
||||||
|
} else if (dataByte !== undefined) {
|
||||||
|
payload.push(dataByte);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSend(cmd, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800 p-4 border-t border-gray-700 select-none ${!isConnected ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
<h3 className="text-sm font-semibold mb-3 text-green-400 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><Navigation size={16} /> ACS Control</span>
|
||||||
|
<span className="text-[10px] bg-gray-900 px-1.5 py-0.5 rounded text-gray-500 border border-gray-700">EXT CMD</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Navigation Commands */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{/* Goto Tag */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Hash size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
list="tag-options"
|
||||||
|
type="text"
|
||||||
|
value={tagId}
|
||||||
|
onChange={(e) => setTagId(e.target.value)}
|
||||||
|
className="w-full bg-gray-900 border border-gray-600 rounded py-1 pl-7 pr-2 text-xs text-white font-mono focus:border-green-500 outline-none"
|
||||||
|
placeholder="Tag ID"
|
||||||
|
/>
|
||||||
|
<datalist id="tag-options">
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<option key={tag} value={tag} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(0x81, tagId)}
|
||||||
|
className="bg-gray-700 hover:bg-green-700 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1 transition-colors border border-gray-600"
|
||||||
|
>
|
||||||
|
GO <ChevronRight size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goto Alias */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Type size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
list="alias-options"
|
||||||
|
type="text"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
className="w-full bg-gray-900 border border-gray-600 rounded py-1 pl-7 pr-2 text-xs text-white font-mono focus:border-green-500 outline-none"
|
||||||
|
placeholder="Alias Name"
|
||||||
|
/>
|
||||||
|
<datalist id="alias-options">
|
||||||
|
{PRESET_ALIASES.map(item => (
|
||||||
|
<option key={item} value={item} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(0x82, alias)}
|
||||||
|
className="bg-gray-700 hover:bg-green-700 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1 transition-colors border border-gray-600"
|
||||||
|
>
|
||||||
|
GO <ChevronRight size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-700 my-3" />
|
||||||
|
|
||||||
|
{/* Action Buttons Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(0x83, undefined, 1)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-blue-900/40 hover:bg-blue-800 border border-blue-800 text-blue-200 py-2 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<Package size={14} /> Pick ON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(0x83, undefined, 0)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-gray-700 hover:bg-gray-600 border border-gray-600 text-gray-300 py-2 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<Package size={14} /> Pick OFF
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(0x84, undefined, 1)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-yellow-900/40 hover:bg-yellow-800 border border-yellow-800 text-yellow-200 py-2 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<Zap size={14} /> Charge ON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSend(0x84, undefined, 0)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-gray-700 hover:bg-gray-600 border border-gray-600 text-gray-300 py-2 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<Zap size={14} /> Charge OFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AcsControls;
|
||||||
104
components/AgvAutoRunControls.tsx
Normal file
104
components/AgvAutoRunControls.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Play, Square, ArrowLeft, ArrowRight, Radar } from 'lucide-react';
|
||||||
|
import { AgvState, AgvRunConfig } from '../types';
|
||||||
|
|
||||||
|
interface AgvAutoRunControlsProps {
|
||||||
|
agvState: AgvState;
|
||||||
|
updateRunConfig: (key: keyof AgvRunConfig, value: any) => void;
|
||||||
|
toggleRun: () => void;
|
||||||
|
isRunning: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
setLidar: (isOn: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgvAutoRunControls: React.FC<AgvAutoRunControlsProps> = ({
|
||||||
|
agvState,
|
||||||
|
updateRunConfig,
|
||||||
|
toggleRun,
|
||||||
|
isRunning,
|
||||||
|
isError,
|
||||||
|
setLidar,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`${isError ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Auto Run</h3>
|
||||||
|
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-full border ${isRunning ? 'bg-green-900/50 border-green-500 text-green-400' : 'bg-gray-800 border-gray-600 text-gray-500'}`}>
|
||||||
|
{isRunning ? 'Running' : 'Ready'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700 shadow-inner space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Direction</span>
|
||||||
|
<div className="flex bg-gray-900 rounded p-1 gap-1">
|
||||||
|
{['FWD', 'BWD'].map(dir => (
|
||||||
|
<button
|
||||||
|
key={dir}
|
||||||
|
onClick={() => updateRunConfig('direction', dir)}
|
||||||
|
disabled={isRunning}
|
||||||
|
className={`text-xs px-3 py-1 rounded transition-colors ${agvState.runConfig.direction === dir ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'}`}
|
||||||
|
>
|
||||||
|
{dir}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Branch</span>
|
||||||
|
<div className="flex bg-gray-900 rounded p-1 gap-1">
|
||||||
|
<button onClick={() => updateRunConfig('branch', 'LEFT')} disabled={isRunning} className={`p-1.5 rounded ${agvState.runConfig.branch === 'LEFT' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-800'}`}><ArrowLeft size={14}/></button>
|
||||||
|
<button onClick={() => updateRunConfig('branch', 'STRAIGHT')} disabled={isRunning} className={`px-2 py-1 text-xs rounded ${agvState.runConfig.branch === 'STRAIGHT' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-800'}`}>STR</button>
|
||||||
|
<button onClick={() => updateRunConfig('branch', 'RIGHT')} disabled={isRunning} className={`p-1.5 rounded ${agvState.runConfig.branch === 'RIGHT' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-800'}`}><ArrowRight size={14}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Speed</span>
|
||||||
|
<div className="flex bg-gray-900 rounded p-1 gap-1">
|
||||||
|
{['L', 'M', 'H'].map(spd => (
|
||||||
|
<button
|
||||||
|
key={spd}
|
||||||
|
onClick={() => updateRunConfig('speedLevel', spd)}
|
||||||
|
disabled={isRunning}
|
||||||
|
className={`w-8 py-1 text-xs rounded ${agvState.runConfig.speedLevel === spd ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'}`}
|
||||||
|
>
|
||||||
|
{spd}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Lidar</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setLidar(!agvState.lidarEnabled)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1 rounded text-xs transition-colors ${
|
||||||
|
agvState.lidarEnabled
|
||||||
|
? 'bg-cyan-600 text-white shadow-sm'
|
||||||
|
: 'bg-gray-700 text-gray-500 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Radar size={12} className={agvState.lidarEnabled ? "animate-pulse" : ""} />
|
||||||
|
{agvState.lidarEnabled ? 'ON' : 'OFF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleRun}
|
||||||
|
className={`w-full py-2.5 rounded font-bold text-sm flex items-center justify-center gap-2 transition-all shadow-md ${
|
||||||
|
isRunning
|
||||||
|
? 'bg-red-600 hover:bg-red-500 text-white ring-2 ring-red-500/50'
|
||||||
|
: 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRunning ? <><Square size={16} fill="currentColor" /> STOP</> : <><Play size={16} fill="currentColor" /> START</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgvAutoRunControls;
|
||||||
167
components/AgvControls.tsx
Normal file
167
components/AgvControls.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { StopCircle, Play, Square, AlertTriangle, ChevronsUp, ChevronsDown, Magnet, Radar, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
|
import { AgvState, AgvMotionState, AgvRunConfig } from '../types';
|
||||||
|
import AgvManualControls from './AgvManualControls';
|
||||||
|
import AgvAutoRunControls from './AgvAutoRunControls';
|
||||||
|
|
||||||
|
interface AgvControlsProps {
|
||||||
|
agvState: AgvState;
|
||||||
|
setMotion: (state: AgvMotionState) => void;
|
||||||
|
setLift: (val: number) => void;
|
||||||
|
setRunConfig: (config: AgvRunConfig) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
onTurn180: (direction: 'LEFT' | 'RIGHT') => void;
|
||||||
|
setMagnet: (isOn: boolean) => void;
|
||||||
|
setLiftStatus: (status: 'IDLE' | 'UP' | 'DOWN') => void;
|
||||||
|
setLidar: (isOn: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgvControls: React.FC<AgvControlsProps> = ({ agvState, setMotion, setLift, setRunConfig, setError, onTurn180, setMagnet, setLiftStatus, setLidar }) => {
|
||||||
|
const isRunning = agvState.motionState === AgvMotionState.RUNNING || agvState.motionState === AgvMotionState.MARK_STOPPING;
|
||||||
|
const isError = agvState.error !== null;
|
||||||
|
|
||||||
|
const updateRunConfig = (key: keyof AgvRunConfig, value: any) => {
|
||||||
|
if (isError) return;
|
||||||
|
setRunConfig({
|
||||||
|
...agvState.runConfig,
|
||||||
|
[key]: value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRun = () => {
|
||||||
|
if (isError) return;
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
setMotion(AgvMotionState.IDLE);
|
||||||
|
} else {
|
||||||
|
const isFwd = agvState.runConfig.direction === 'FWD';
|
||||||
|
const hasLine = isFwd ? agvState.sensorLineFront : agvState.sensorLineRear;
|
||||||
|
|
||||||
|
if (!hasLine) {
|
||||||
|
setError('LINE_OUT');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMotion(AgvMotionState.RUNNING);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkStop = () => {
|
||||||
|
if (agvState.motionState === AgvMotionState.RUNNING) {
|
||||||
|
setRunConfig({
|
||||||
|
...agvState.runConfig,
|
||||||
|
speedLevel: 'L'
|
||||||
|
});
|
||||||
|
setMotion(AgvMotionState.MARK_STOPPING);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetError = () => {
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLiftSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (agvState.liftStatus !== 'IDLE') {
|
||||||
|
setLiftStatus('IDLE');
|
||||||
|
}
|
||||||
|
setLift(parseInt(e.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-3 bg-gray-900 border-b border-gray-700">
|
||||||
|
|
||||||
|
{/* Error Overlay */}
|
||||||
|
{isError && (
|
||||||
|
<div className="bg-red-900/80 border border-red-600 p-3 rounded text-center animate-pulse">
|
||||||
|
<div className="text-red-100 font-bold text-sm flex items-center justify-center gap-2 mb-2">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
{agvState.error}
|
||||||
|
</div>
|
||||||
|
<button onClick={resetError} className="bg-red-600 hover:bg-red-500 text-white text-xs font-bold px-3 py-1 rounded shadow-sm">
|
||||||
|
RESET ERROR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Operation (분리된 콤포넌트) */}
|
||||||
|
<AgvManualControls
|
||||||
|
agvState={agvState}
|
||||||
|
setMotion={setMotion}
|
||||||
|
updateRunConfig={updateRunConfig}
|
||||||
|
onTurn180={onTurn180}
|
||||||
|
handleMarkStop={handleMarkStop}
|
||||||
|
isRunning={isRunning}
|
||||||
|
isError={isError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Lift & Magnet */}
|
||||||
|
<div className={`px-1 ${isError ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||||
|
<span className="flex items-center gap-1"><ChevronsUp size={12} /> Lift Height</span>
|
||||||
|
<span className="font-mono text-white">{Math.round(agvState.liftHeight)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLiftStatus('DOWN')}
|
||||||
|
disabled={agvState.liftHeight === 0}
|
||||||
|
className={`p-1.5 rounded disabled:opacity-30 transition-colors ${agvState.liftStatus === 'DOWN'
|
||||||
|
? 'bg-blue-600 text-white shadow-[0_0_10px_rgba(37,99,235,0.5)]'
|
||||||
|
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
|
||||||
|
}`}
|
||||||
|
title="Lower Lift"
|
||||||
|
>
|
||||||
|
<ChevronsDown size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={agvState.liftHeight}
|
||||||
|
onChange={handleLiftSliderChange}
|
||||||
|
className="flex-1 h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setLiftStatus('UP')}
|
||||||
|
disabled={agvState.liftHeight === 100}
|
||||||
|
className={`p-1.5 rounded disabled:opacity-30 transition-colors ${agvState.liftStatus === 'UP'
|
||||||
|
? 'bg-blue-600 text-white shadow-[0_0_10px_rgba(37,99,235,0.5)]'
|
||||||
|
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
|
||||||
|
}`}
|
||||||
|
title="Raise Lift"
|
||||||
|
>
|
||||||
|
<ChevronsUp size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMagnet(!agvState.magnetOn)}
|
||||||
|
className={`w-full py-1.5 text-xs rounded border flex items-center justify-center gap-2 transition-colors ${agvState.magnetOn
|
||||||
|
? 'bg-orange-600 text-white border-orange-500'
|
||||||
|
: 'bg-gray-800 text-gray-400 border-gray-600 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Magnet size={14} className={agvState.magnetOn ? "animate-pulse" : ""} />
|
||||||
|
MAGNET {agvState.magnetOn ? 'ON' : 'OFF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-800" />
|
||||||
|
|
||||||
|
{/* Auto Run Controls */}
|
||||||
|
{/* Auto Run Controls (분리된 콤포넌트) */}
|
||||||
|
<AgvAutoRunControls
|
||||||
|
agvState={agvState}
|
||||||
|
updateRunConfig={updateRunConfig}
|
||||||
|
toggleRun={toggleRun}
|
||||||
|
isRunning={isRunning}
|
||||||
|
isError={isError}
|
||||||
|
setLidar={setLidar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgvControls;
|
||||||
119
components/AgvManualControls.tsx
Normal file
119
components/AgvManualControls.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { ArrowUp, ArrowDown, RotateCcw, RotateCw, StopCircle, Disc } from 'lucide-react';
|
||||||
|
import { AgvState, AgvMotionState, AgvRunConfig } from '../types';
|
||||||
|
|
||||||
|
interface AgvManualControlsProps {
|
||||||
|
agvState: AgvState;
|
||||||
|
setMotion: (state: AgvMotionState) => void;
|
||||||
|
updateRunConfig: (key: keyof AgvRunConfig, value: any) => void;
|
||||||
|
onTurn180: (direction: 'LEFT' | 'RIGHT') => void;
|
||||||
|
handleMarkStop: () => void;
|
||||||
|
isRunning: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgvManualControls: React.FC<AgvManualControlsProps> = ({
|
||||||
|
agvState,
|
||||||
|
setMotion,
|
||||||
|
updateRunConfig,
|
||||||
|
onTurn180,
|
||||||
|
handleMarkStop,
|
||||||
|
isRunning,
|
||||||
|
isError,
|
||||||
|
}) => {
|
||||||
|
const setManualMotion = (motion: AgvMotionState, direction?: 'FWD' | 'BWD') => {
|
||||||
|
setMotion(motion);
|
||||||
|
if (direction) {
|
||||||
|
updateRunConfig('direction', direction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${isError ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
<div className="flex justify-between items-center mb-2 px-1">
|
||||||
|
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Manual Operation</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700 shadow-inner">
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div />
|
||||||
|
<button
|
||||||
|
disabled={isRunning}
|
||||||
|
onMouseDown={() => setManualMotion(AgvMotionState.FORWARD, 'FWD')}
|
||||||
|
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
|
||||||
|
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
|
||||||
|
>
|
||||||
|
<ArrowUp size={24} />
|
||||||
|
</button>
|
||||||
|
<div />
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={isRunning}
|
||||||
|
onMouseDown={() => setManualMotion(AgvMotionState.TURN_LEFT)}
|
||||||
|
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
|
||||||
|
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
|
||||||
|
>
|
||||||
|
<RotateCcw size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setManualMotion(AgvMotionState.IDLE)}
|
||||||
|
className="h-10 bg-red-800 hover:bg-red-700 rounded flex items-center justify-center text-white shadow"
|
||||||
|
>
|
||||||
|
<StopCircle size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={isRunning}
|
||||||
|
onMouseDown={() => setManualMotion(AgvMotionState.TURN_RIGHT)}
|
||||||
|
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
|
||||||
|
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
|
||||||
|
>
|
||||||
|
<RotateCw size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div />
|
||||||
|
<button
|
||||||
|
disabled={isRunning}
|
||||||
|
onMouseDown={() => setManualMotion(AgvMotionState.BACKWARD, 'BWD')}
|
||||||
|
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
|
||||||
|
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
|
||||||
|
>
|
||||||
|
<ArrowDown size={24} />
|
||||||
|
</button>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extra Actions */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
disabled={isRunning}
|
||||||
|
onClick={() => onTurn180('LEFT')}
|
||||||
|
className="text-xs py-1.5 bg-gray-700 rounded hover:bg-gray-600 flex items-center justify-center gap-1 disabled:opacity-30 shadow-sm"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} /> L-Turn 180
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={isRunning}
|
||||||
|
onClick={() => onTurn180('RIGHT')}
|
||||||
|
className="text-xs py-1.5 bg-gray-700 rounded hover:bg-gray-600 flex items-center justify-center gap-1 disabled:opacity-30 shadow-sm"
|
||||||
|
>
|
||||||
|
<RotateCw size={12} /> R-Turn 180
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!isRunning || agvState.motionState === AgvMotionState.MARK_STOPPING}
|
||||||
|
onClick={handleMarkStop}
|
||||||
|
className={`col-span-2 text-xs py-1.5 border rounded flex items-center justify-center gap-1 shadow-sm transition-colors disabled:opacity-30 ${
|
||||||
|
agvState.motionState === AgvMotionState.MARK_STOPPING
|
||||||
|
? 'bg-yellow-600 text-black border-yellow-500 font-bold'
|
||||||
|
: 'bg-yellow-900/50 text-yellow-200 border-yellow-800/50 hover:bg-yellow-900/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Disc size={12} /> {agvState.motionState === AgvMotionState.MARK_STOPPING ? 'Stopping at Mark...' : 'Drive to Next Mark'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgvManualControls;
|
||||||
104
components/AgvStatusPanel.tsx
Normal file
104
components/AgvStatusPanel.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AgvState, AgvError, AgvSignal, SystemFlag0, SystemFlag1 } from '../types';
|
||||||
|
import { CircleCheck, AlertOctagon, Cpu } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AgvStatusPanelProps {
|
||||||
|
agvState: AgvState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
|
||||||
|
|
||||||
|
const renderBit = (label: string, value: number, bitIndex: number, colorClass = 'bg-green-500') => {
|
||||||
|
const isOn = (value & (1 << bitIndex)) !== 0;
|
||||||
|
// 필터링에 의해 isOn이 true인 것만 전달되지만 스타일 유지를 위해 체크 로직 유지
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between text-[10px] py-0.5 border-b border-gray-800 last:border-0 animate-in fade-in slide-in-from-left-1 duration-200">
|
||||||
|
<span className="text-gray-300 truncate pr-2" title={label}>{label.replace(/_/g, ' ')}</span>
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOn ? colorClass : 'bg-gray-700'} shadow-sm`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnumKeys = (e: any) => {
|
||||||
|
return Object.keys(e).filter(k => typeof e[k as any] === "number");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveKeys = (enumObj: any, value: number) => {
|
||||||
|
return getEnumKeys(enumObj).filter(key => (value & (1 << (enumObj[key as any] as number))) !== 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-900 border-l border-gray-700 overflow-y-auto">
|
||||||
|
<div className="p-3 bg-gray-800 border-b border-gray-700 font-bold text-xs text-center text-gray-200 sticky top-0 z-10">
|
||||||
|
AGV PROTOCOL FLAGS (ACTIVE)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System 1 (Control State) */}
|
||||||
|
<div className="p-2 border-b border-gray-700">
|
||||||
|
<h4 className="text-xs font-bold text-blue-400 mb-2 flex items-center gap-1">
|
||||||
|
<Cpu size={12} /> SYSTEM 1
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
|
||||||
|
{getActiveKeys(SystemFlag1, agvState.system1).length > 0 ? (
|
||||||
|
getActiveKeys(SystemFlag1, agvState.system1).map((key) =>
|
||||||
|
renderBit(key, agvState.system1, SystemFlag1[key as any] as unknown as number, 'bg-blue-500')
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-600 italic px-1">No active flags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signals */}
|
||||||
|
<div className="p-2 border-b border-gray-700">
|
||||||
|
<h4 className="text-xs font-bold text-yellow-400 mb-2 flex items-center gap-1">
|
||||||
|
<CircleCheck size={12} /> SIGNALS
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
|
||||||
|
{getActiveKeys(AgvSignal, agvState.signalFlags).length > 0 ? (
|
||||||
|
getActiveKeys(AgvSignal, agvState.signalFlags).map((key) =>
|
||||||
|
renderBit(key, agvState.signalFlags, AgvSignal[key as any] as unknown as number, 'bg-yellow-500')
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-600 italic px-1">No active signals</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
<div className="p-2 border-b border-gray-700">
|
||||||
|
<h4 className="text-xs font-bold text-red-400 mb-2 flex items-center gap-1">
|
||||||
|
<AlertOctagon size={12} /> ERRORS
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
|
||||||
|
{getActiveKeys(AgvError, agvState.errorFlags).length > 0 ? (
|
||||||
|
getActiveKeys(AgvError, agvState.errorFlags).map((key) =>
|
||||||
|
renderBit(key, agvState.errorFlags, AgvError[key as any] as unknown as number, 'bg-red-600 shadow-[0_0_5px_rgba(220,38,38,0.5)]')
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-600 italic px-1">None</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System 0 (Hardware) */}
|
||||||
|
<div className="p-2 border-b border-gray-700">
|
||||||
|
<h4 className="text-xs font-bold text-cyan-400 mb-2 flex items-center gap-1">
|
||||||
|
<Cpu size={12} /> SYSTEM 0
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
|
||||||
|
{getActiveKeys(SystemFlag0, agvState.system0).length > 0 ? (
|
||||||
|
getActiveKeys(SystemFlag0, agvState.system0).map((key) =>
|
||||||
|
renderBit(key, agvState.system0, SystemFlag0[key as any] as unknown as number, 'bg-cyan-500')
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-600 italic px-1">No active flags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgvStatusPanel;
|
||||||
88
components/BmsPanel.tsx
Normal file
88
components/BmsPanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BatteryCharging, Zap, Thermometer, Sliders } from 'lucide-react';
|
||||||
|
import { AgvState } from '../types';
|
||||||
|
|
||||||
|
interface BmsPanelProps {
|
||||||
|
state: AgvState;
|
||||||
|
setBatteryLevel: (level: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BmsPanel: React.FC<BmsPanelProps> = ({ state, setBatteryLevel }) => {
|
||||||
|
const currentCapacity = (state.maxCapacity * (state.batteryLevel / 100)).toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 p-4 border-t border-gray-700 h-auto select-none">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><BatteryCharging size={16} /> BMS Monitor</span>
|
||||||
|
<span className="text-xs font-mono text-gray-500">ID: 01</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Main Info Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
|
||||||
|
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Total Voltage</div>
|
||||||
|
<div className="text-lg font-mono text-white font-bold flex items-baseline gap-1">
|
||||||
|
{(state.cellVoltages.reduce((a,b)=>a+b, 0)).toFixed(1)} <span className="text-xs text-gray-400">V</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
|
||||||
|
<div className="text-[10px] text-gray-400 flex items-center gap-1 uppercase tracking-wider">
|
||||||
|
<Thermometer size={10} /> Temp
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-mono text-white font-bold flex items-baseline gap-1">
|
||||||
|
{state.batteryTemp.toFixed(1)} <span className="text-xs text-gray-400">°C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
|
||||||
|
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Capacity (Ah)</div>
|
||||||
|
<div className="text-sm font-mono text-white">
|
||||||
|
<span className="text-yellow-400 font-bold">{currentCapacity}</span>
|
||||||
|
<span className="text-gray-500"> / </span>
|
||||||
|
{state.maxCapacity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
|
||||||
|
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Level (%)</div>
|
||||||
|
<div className={`text-sm font-mono font-bold ${state.batteryLevel < 20 ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{state.batteryLevel.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Slider */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||||
|
<span className="flex items-center gap-1"><Sliders size={10}/> Manual Adjust</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={state.batteryLevel}
|
||||||
|
onChange={(e) => setBatteryLevel(Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cells */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1">Cell Voltages (V)</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{state.cellVoltages.map((v, i) => (
|
||||||
|
<div key={i} className={`py-1 px-0.5 text-center rounded text-[10px] font-mono border ${
|
||||||
|
v < 2.9 ? 'bg-red-900/30 border-red-800 text-red-300' :
|
||||||
|
v > 3.5 ? 'bg-blue-900/30 border-blue-800 text-blue-300' :
|
||||||
|
'bg-gray-700/30 border-gray-600 text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{v.toFixed(3)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BmsPanel;
|
||||||
99
components/ConnectionStatusBar.tsx
Normal file
99
components/ConnectionStatusBar.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Plug, Unplug, Activity, Battery } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConnectionStatusBarProps {
|
||||||
|
agvConnected: boolean;
|
||||||
|
bmsConnected: boolean;
|
||||||
|
onConnectAgv: () => void;
|
||||||
|
onDisconnectAgv: () => void;
|
||||||
|
onConnectBms: () => void;
|
||||||
|
onDisconnectBms: () => void;
|
||||||
|
agvBaudRate: number;
|
||||||
|
setAgvBaudRate: (rate: number) => void;
|
||||||
|
bmsBaudRate: number;
|
||||||
|
setBmsBaudRate: (rate: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200];
|
||||||
|
|
||||||
|
const ConnectionStatusBar: React.FC<ConnectionStatusBarProps> = ({
|
||||||
|
agvConnected,
|
||||||
|
bmsConnected,
|
||||||
|
onConnectAgv,
|
||||||
|
onDisconnectAgv,
|
||||||
|
onConnectBms,
|
||||||
|
onDisconnectBms,
|
||||||
|
agvBaudRate,
|
||||||
|
setAgvBaudRate,
|
||||||
|
bmsBaudRate,
|
||||||
|
setBmsBaudRate,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="h-10 bg-gray-900 border-t border-gray-700 flex items-center justify-between px-4 select-none shrink-0">
|
||||||
|
|
||||||
|
{/* AGV Connection Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex items-center gap-2 px-2 py-1 rounded border ${agvConnected ? 'bg-blue-900/30 border-blue-700' : 'bg-gray-800 border-gray-700'}`}>
|
||||||
|
<span className="text-xs font-bold text-gray-300 flex items-center gap-1">
|
||||||
|
<Activity size={14} className={agvConnected ? "text-green-400" : "text-gray-500"} />
|
||||||
|
AGV
|
||||||
|
</span>
|
||||||
|
<div className="h-4 w-px bg-gray-700 mx-1" />
|
||||||
|
<select
|
||||||
|
value={agvBaudRate}
|
||||||
|
onChange={(e) => setAgvBaudRate(Number(e.target.value))}
|
||||||
|
disabled={agvConnected}
|
||||||
|
className="bg-transparent text-xs text-white outline-none disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{BAUD_RATES.map(rate => <option key={rate} value={rate} className="bg-gray-800">{rate}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={agvConnected ? onDisconnectAgv : onConnectAgv}
|
||||||
|
className={`ml-2 px-2 py-0.5 text-[10px] font-bold rounded flex items-center gap-1 transition-colors ${
|
||||||
|
agvConnected
|
||||||
|
? 'bg-red-900/50 text-red-300 hover:bg-red-900'
|
||||||
|
: 'bg-blue-700 text-white hover:bg-blue-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{agvConnected ? <><Unplug size={10} /> DISCONNECT</> : <><Plug size={10} /> CONNECT</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-600 font-mono">
|
||||||
|
SERIAL COMMUNICATION
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BMS Connection Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex items-center gap-2 px-2 py-1 rounded border ${bmsConnected ? 'bg-yellow-900/30 border-yellow-700' : 'bg-gray-800 border-gray-700'}`}>
|
||||||
|
<span className="text-xs font-bold text-gray-300 flex items-center gap-1">
|
||||||
|
<Battery size={14} className={bmsConnected ? "text-yellow-400" : "text-gray-500"} />
|
||||||
|
BMS
|
||||||
|
</span>
|
||||||
|
<div className="h-4 w-px bg-gray-700 mx-1" />
|
||||||
|
<select
|
||||||
|
value={bmsBaudRate}
|
||||||
|
onChange={(e) => setBmsBaudRate(Number(e.target.value))}
|
||||||
|
disabled={bmsConnected}
|
||||||
|
className="bg-transparent text-xs text-white outline-none disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{BAUD_RATES.map(rate => <option key={rate} value={rate} className="bg-gray-800">{rate}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={bmsConnected ? onDisconnectBms : onConnectBms}
|
||||||
|
className={`ml-2 px-2 py-0.5 text-[10px] font-bold rounded flex items-center gap-1 transition-colors ${
|
||||||
|
bmsConnected
|
||||||
|
? 'bg-red-900/50 text-red-300 hover:bg-red-900'
|
||||||
|
: 'bg-blue-700 text-white hover:bg-blue-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bmsConnected ? <><Unplug size={10} /> DISCONNECT</> : <><Plug size={10} /> CONNECT</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConnectionStatusBar;
|
||||||
51
components/EditorToolbar.tsx
Normal file
51
components/EditorToolbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MousePointer2, GitCommitHorizontal, Radio, Disc, Eraser, Save, Upload, Minus, Spline } from 'lucide-react';
|
||||||
|
import { ToolType } from '../types';
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
activeTool: ToolType;
|
||||||
|
setTool: (t: ToolType) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onLoad: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorToolbar: React.FC<EditorToolbarProps> = ({ activeTool, setTool, onSave, onLoad }) => {
|
||||||
|
const tools = [
|
||||||
|
{ id: ToolType.SELECT, icon: <MousePointer2 size={20} />, label: 'Select/Move' },
|
||||||
|
{ id: ToolType.DRAW_LINE, icon: <GitCommitHorizontal size={20} />, label: 'Logical Connection (Graph)' },
|
||||||
|
{ id: ToolType.DRAW_MAGNET_STRAIGHT, icon: <Minus size={20} />, label: 'Magnet Line (Straight)' },
|
||||||
|
{ id: ToolType.DRAW_MAGNET_CURVE, icon: <Spline size={20} />, label: 'Magnet Line (Curve)' },
|
||||||
|
{ id: ToolType.ADD_RFID, icon: <Radio size={20} />, label: 'Place RFID' },
|
||||||
|
{ id: ToolType.ADD_MARK, icon: <Disc size={20} />, label: 'Place Mark' },
|
||||||
|
{ id: ToolType.ERASER, icon: <Eraser size={20} />, label: 'Delete' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 bg-gray-800 p-2 rounded-lg border border-gray-700 h-full">
|
||||||
|
<div className="text-xs font-bold text-gray-400 mb-2 uppercase text-center">Map Editor</div>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<button
|
||||||
|
key={tool.id}
|
||||||
|
onClick={() => setTool(tool.id)}
|
||||||
|
title={tool.label}
|
||||||
|
className={`p-3 rounded-md transition-colors flex justify-center items-center ${
|
||||||
|
activeTool === tool.id
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tool.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="h-px bg-gray-600 my-2" />
|
||||||
|
<button onClick={onSave} className="p-3 bg-green-700 hover:bg-green-600 text-white rounded-md flex justify-center" title="Save Map">
|
||||||
|
<Save size={20} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onLoad} className="p-3 bg-yellow-700 hover:bg-yellow-600 text-white rounded-md flex justify-center" title="Load Map">
|
||||||
|
<Upload size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorToolbar;
|
||||||
134
components/SerialConsole.tsx
Normal file
134
components/SerialConsole.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { LogEntry } from '../types';
|
||||||
|
import { Terminal, Plug, Unplug, Settings2, Trash2, Usb } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SerialConsoleProps {
|
||||||
|
title: string;
|
||||||
|
logs: LogEntry[];
|
||||||
|
isConnected: boolean;
|
||||||
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
baudRate: number;
|
||||||
|
setBaudRate: (rate: number) => void;
|
||||||
|
portInfo?: string | null;
|
||||||
|
colorClass?: string; // Text color for the source label
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200];
|
||||||
|
|
||||||
|
const SerialConsole: React.FC<SerialConsoleProps> = ({
|
||||||
|
title,
|
||||||
|
logs,
|
||||||
|
isConnected,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
onClear,
|
||||||
|
baudRate,
|
||||||
|
setBaudRate,
|
||||||
|
portInfo,
|
||||||
|
colorClass = 'text-gray-400'
|
||||||
|
}) => {
|
||||||
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Memoize reversed logs to prevent unnecessary array operations on every render
|
||||||
|
const reversedLogs = useMemo(() => [...logs].reverse(), [logs]);
|
||||||
|
|
||||||
|
// Force scroll to top whenever logs update to ensure the newest (top) is visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (logContainerRef.current) {
|
||||||
|
logContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [reversedLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-950 border-r border-gray-800 last:border-r-0 overflow-hidden relative group">
|
||||||
|
{/* Header with Connection Controls */}
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<div className={`font-bold text-xs ${colorClass} flex items-center gap-1.5 whitespace-nowrap`}>
|
||||||
|
<Terminal size={12} /> {title}
|
||||||
|
</div>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isConnected ? 'bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.6)]' : 'bg-red-500/50'}`} />
|
||||||
|
|
||||||
|
{isConnected && portInfo && (
|
||||||
|
<div className="hidden sm:flex items-center gap-1 text-[9px] text-gray-500 bg-black/20 px-1.5 py-0.5 rounded border border-gray-800/50 truncate max-w-[100px]">
|
||||||
|
<Usb size={9} />
|
||||||
|
{portInfo.replace('ID:', '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
value={baudRate}
|
||||||
|
onChange={(e) => setBaudRate(Number(e.target.value))}
|
||||||
|
disabled={isConnected}
|
||||||
|
className="bg-gray-800 text-[9px] text-gray-300 border border-gray-700 rounded px-1 py-0.5 outline-none focus:border-blue-500 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{BAUD_RATES.map(rate => <option key={rate} value={rate}>{rate}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={isConnected ? onDisconnect : onConnect}
|
||||||
|
title={isConnected ? "Disconnect" : "Connect"}
|
||||||
|
className={`p-1 rounded transition-colors ${
|
||||||
|
isConnected
|
||||||
|
? 'text-red-400 hover:bg-red-900/30'
|
||||||
|
: 'text-blue-400 hover:bg-blue-900/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isConnected ? <Unplug size={14} /> : <Plug size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-3 bg-gray-800 mx-0.5"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
title="Clear Logs"
|
||||||
|
className="p-1 text-gray-500 hover:text-gray-200 hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Body */}
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="flex-1 overflow-y-auto p-2 font-mono text-[10px] space-y-0.5 leading-tight bg-black"
|
||||||
|
>
|
||||||
|
{reversedLogs.length === 0 && (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-gray-800 space-y-1">
|
||||||
|
<Settings2 size={20} className="opacity-20" />
|
||||||
|
<span className="text-[9px]">No Data</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reversedLogs.map((log, index) => (
|
||||||
|
<div key={log.id} className="flex gap-2 break-all opacity-90 hover:opacity-100 hover:bg-gray-900/30 py-[1px] border-b border-gray-900/50 last:border-0 items-start">
|
||||||
|
{/* Row Number (1 = Newest) */}
|
||||||
|
<span className="text-gray-700 select-none min-w-[20px] text-right text-[9px] pt-0.5">{index + 1}</span>
|
||||||
|
|
||||||
|
<span className="text-gray-600 select-none whitespace-nowrap min-w-[45px] text-[9px] pt-0.5">{log.timestamp}</span>
|
||||||
|
<span className={`font-bold select-none whitespace-nowrap w-4 text-center text-[9px] pt-0.5 ${
|
||||||
|
log.type === 'TX' ? 'text-green-600' : log.type === 'RX' ? 'text-purple-500' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{log.type === 'RX' ? 'RX' : log.type === 'TX' ? 'TX' : '--'}
|
||||||
|
</span>
|
||||||
|
<span className={`flex-1 font-mono break-all
|
||||||
|
${log.type === 'TX' ? 'text-green-200/90' : ''}
|
||||||
|
${log.type === 'RX' ? 'text-purple-200/90' : ''}
|
||||||
|
${log.type === 'ERROR' ? 'text-red-400' : ''}
|
||||||
|
${log.type === 'INFO' ? 'text-gray-500 italic' : ''}
|
||||||
|
`}>
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SerialConsole;
|
||||||
1561
components/SimulationCanvas.tsx
Normal file
1561
components/SimulationCanvas.tsx
Normal file
File diff suppressed because it is too large
Load Diff
69
components/SystemLogPanel.tsx
Normal file
69
components/SystemLogPanel.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { LogEntry } from '../types';
|
||||||
|
import { Terminal, Trash2, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SystemLogPanelProps {
|
||||||
|
logs: LogEntry[];
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemLogPanel: React.FC<SystemLogPanelProps> = ({ logs, onClear }) => {
|
||||||
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const reversedLogs = useMemo(() => [...logs].reverse(), [logs]);
|
||||||
|
|
||||||
|
// Force scroll to top
|
||||||
|
useEffect(() => {
|
||||||
|
if (logContainerRef.current) {
|
||||||
|
logContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [reversedLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-900 border-t border-gray-700 overflow-hidden relative">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-800 border-b border-gray-700 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-bold text-xs text-gray-300 flex items-center gap-1.5">
|
||||||
|
<Info size={12} className="text-gray-400" /> SYSTEM LOG
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
title="Clear Logs"
|
||||||
|
className="p-1 text-gray-500 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Body */}
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="flex-1 overflow-y-auto p-2 font-mono text-[10px] space-y-0.5 leading-tight bg-gray-900"
|
||||||
|
>
|
||||||
|
{reversedLogs.length === 0 && (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-gray-700 space-y-1">
|
||||||
|
<span className="text-[9px]">No System Activity</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reversedLogs.map((log, index) => (
|
||||||
|
<div key={log.id} className="flex gap-2 break-all opacity-90 hover:opacity-100 hover:bg-gray-800/50 py-[1px] border-b border-gray-800/30 last:border-0 items-start">
|
||||||
|
<span className="text-gray-600 select-none min-w-[20px] text-right text-[9px] pt-0.5">{index + 1}</span>
|
||||||
|
<span className="text-gray-500 select-none whitespace-nowrap min-w-[45px] text-[9px] pt-0.5">{log.timestamp}</span>
|
||||||
|
<span className={`flex-1 font-mono break-all
|
||||||
|
${log.type === 'ERROR' ? 'text-red-400 font-bold' : 'text-gray-300'}
|
||||||
|
${log.type === 'INFO' ? 'text-gray-400' : ''}
|
||||||
|
`}>
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemLogPanel;
|
||||||
32
constants.ts
Normal file
32
constants.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
export const AGV_WIDTH = 40;
|
||||||
|
export const AGV_HEIGHT = 60; // Length
|
||||||
|
export const WHEEL_RADIUS = 8;
|
||||||
|
export const MAX_SPEED = 2.0;
|
||||||
|
export const TURN_SPEED = 1.5;
|
||||||
|
export const MARK_SEARCH_SPEED = 0.5;
|
||||||
|
export const GRID_SIZE = 50;
|
||||||
|
|
||||||
|
// Magnet Line Visuals
|
||||||
|
export const MAGNET_WIDTH = 10;
|
||||||
|
export const MAGNET_COLOR = 'rgba(59, 130, 246, 0.4)'; // Transparent Blue
|
||||||
|
export const MAGNET_COLOR_ACTIVE = 'rgba(59, 130, 246, 0.6)';
|
||||||
|
|
||||||
|
// Run Mode Speeds
|
||||||
|
export const SPEED_L = 0.5;
|
||||||
|
export const SPEED_M = 1.5;
|
||||||
|
export const SPEED_H = 3.0;
|
||||||
|
|
||||||
|
// Sensor positions relative to center (0,0)
|
||||||
|
// AGV faces "Right" (0 deg) in local calculation space before rotation
|
||||||
|
export const SENSOR_OFFSET_FRONT = { x: 28, y: 0 };
|
||||||
|
export const SENSOR_OFFSET_REAR = { x: -28, y: 0 };
|
||||||
|
// Mark sensor: Shifted left relative to forward direction
|
||||||
|
export const SENSOR_OFFSET_MARK = { x: 0, y: -12 };
|
||||||
|
|
||||||
|
export const INITIAL_MAP = {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
magnets: [],
|
||||||
|
marks: [],
|
||||||
|
};
|
||||||
41
index.html
Normal file
41
index.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AGV Simulator</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
/* Custom scrollbar for dark theme */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||||
|
"lucide-react": "https://esm.sh/lucide-react@^0.561.0",
|
||||||
|
"react/": "https://esm.sh/react@^19.2.3/",
|
||||||
|
"react": "https://esm.sh/react@^19.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-white overflow-hidden">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
index.tsx
Normal file
15
index.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element to mount to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
7
metadata.json
Normal file
7
metadata.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"description": "Generated by Gemini.",
|
||||||
|
"requestFramePermissions": [
|
||||||
|
"serial"
|
||||||
|
],
|
||||||
|
"name": "AGV Emulator"
|
||||||
|
}
|
||||||
1771
package-lock.json
generated
Normal file
1771
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "agv-emulator",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"lucide-react": "^0.561.0",
|
||||||
|
"react": "^19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
124
services/serialService.ts
Normal file
124
services/serialService.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export type SerialDataCallback = (data: Uint8Array) => void;
|
||||||
|
|
||||||
|
// Define Web Serial API types
|
||||||
|
interface SerialPortInfo {
|
||||||
|
usbVendorId?: number;
|
||||||
|
usbProductId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SerialPort {
|
||||||
|
open(options: { baudRate: number }): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
getInfo(): SerialPortInfo;
|
||||||
|
readable: ReadableStream<Uint8Array> | null;
|
||||||
|
writable: WritableStream<Uint8Array> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Navigator {
|
||||||
|
serial: {
|
||||||
|
requestPort(options?: any): Promise<SerialPort>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SerialPortHandler {
|
||||||
|
private port: SerialPort | null = null;
|
||||||
|
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||||
|
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
||||||
|
private isConnected: boolean = false;
|
||||||
|
private onData: SerialDataCallback;
|
||||||
|
|
||||||
|
constructor(onData: SerialDataCallback) {
|
||||||
|
this.onData = onData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(baudRate: number = 9600) {
|
||||||
|
if (!navigator.serial) {
|
||||||
|
throw new Error('Web Serial API not supported in this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.port = await navigator.serial.requestPort();
|
||||||
|
await this.port.open({ baudRate });
|
||||||
|
|
||||||
|
if (this.port.readable) {
|
||||||
|
this.reader = this.port.readable.getReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.port.writable) {
|
||||||
|
this.writer = this.port.writable.getWriter();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = true;
|
||||||
|
this.readLoop();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'NotFoundError') {
|
||||||
|
throw new Error('USER_CANCELLED');
|
||||||
|
}
|
||||||
|
console.error('Serial connection failed:', error);
|
||||||
|
this.disconnect();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
if (this.reader) {
|
||||||
|
await this.reader.cancel();
|
||||||
|
this.reader.releaseLock();
|
||||||
|
}
|
||||||
|
if (this.writer) {
|
||||||
|
await this.writer.close();
|
||||||
|
this.writer.releaseLock();
|
||||||
|
}
|
||||||
|
if (this.port) {
|
||||||
|
await this.port.close();
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
this.port = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(data: string | Uint8Array) {
|
||||||
|
if (this.writer && this.isConnected) {
|
||||||
|
let payload: Uint8Array;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
payload = new TextEncoder().encode(data + '\n');
|
||||||
|
} else {
|
||||||
|
payload = data;
|
||||||
|
}
|
||||||
|
await this.writer.write(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPortInfo(): string | null {
|
||||||
|
if (this.port) {
|
||||||
|
const info = this.port.getInfo();
|
||||||
|
if (info.usbVendorId && info.usbProductId) {
|
||||||
|
return `ID:${info.usbVendorId.toString(16).padStart(4,'0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4,'0').toUpperCase()}`;
|
||||||
|
}
|
||||||
|
return "USB Device";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readLoop() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const { value, done } = await this.reader!.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
this.onData(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Serial read error:', error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected() {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
282
types.ts
Normal file
282
types.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export enum ToolType {
|
||||||
|
SELECT = 'SELECT',
|
||||||
|
DRAW_LINE = 'DRAW_LINE', // Logical Edge
|
||||||
|
DRAW_MAGNET_STRAIGHT = 'DRAW_MAGNET_STRAIGHT', // Physical Line
|
||||||
|
DRAW_MAGNET_CURVE = 'DRAW_MAGNET_CURVE', // Physical Curve
|
||||||
|
ADD_RFID = 'ADD_RFID',
|
||||||
|
ADD_MARK = 'ADD_MARK',
|
||||||
|
ERASER = 'ERASER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C# MapNode Type Enum (Internal use)
|
||||||
|
export enum NodeType {
|
||||||
|
Normal = 0,
|
||||||
|
Loader = 1,
|
||||||
|
UnLoader = 2,
|
||||||
|
Charging = 3, // Used for Cleaner in new map
|
||||||
|
Buffer = 4,
|
||||||
|
ChargerStation = 5,
|
||||||
|
Label = 6,
|
||||||
|
Image = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
// React Internal Node Structure
|
||||||
|
export interface MapNode extends Point {
|
||||||
|
id: string; // NodeId
|
||||||
|
type: number; // NodeType (Internal enum)
|
||||||
|
name: string;
|
||||||
|
rfidId: string; // RFID is property of Node
|
||||||
|
connectedNodes: string[]; // Keep track for export
|
||||||
|
|
||||||
|
// Visual props
|
||||||
|
labelText?: string;
|
||||||
|
foreColor?: string;
|
||||||
|
backColor?: string;
|
||||||
|
imageBase64?: string;
|
||||||
|
displayColor?: string;
|
||||||
|
|
||||||
|
// New props preservation
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapEdge {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical Magnet Line for AGV to follow
|
||||||
|
export interface MagnetLine {
|
||||||
|
id: string;
|
||||||
|
type: 'STRAIGHT' | 'CURVE';
|
||||||
|
p1: Point;
|
||||||
|
p2: Point;
|
||||||
|
controlPoint?: Point; // Required for CURVE (Quadratic Bezier control point)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark Sensor now needs rotation to be "crossed" on the line
|
||||||
|
export interface FloorMark extends Point {
|
||||||
|
id: string;
|
||||||
|
rotation: number; // Degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationMap {
|
||||||
|
nodes: MapNode[];
|
||||||
|
edges: MapEdge[]; // Logical Graph Connections
|
||||||
|
magnets: MagnetLine[]; // Physical Guide Tape
|
||||||
|
marks: FloorMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C# Data Transfer Objects (New JSON Structure) ---
|
||||||
|
|
||||||
|
export interface CSharpNode {
|
||||||
|
Id: string;
|
||||||
|
Text: string;
|
||||||
|
Position: string; // "x, y"
|
||||||
|
Type: number; // Usually 0 for nodes
|
||||||
|
StationType: number; // Functional type
|
||||||
|
ConnectedNodes: string[];
|
||||||
|
RfidId: string;
|
||||||
|
NodeTextForeColor?: string;
|
||||||
|
NodeTextFontSize?: number;
|
||||||
|
// Flags (Optional for TS if not used logic-side, but good to preserve)
|
||||||
|
CanDocking?: boolean;
|
||||||
|
DockDirection?: number;
|
||||||
|
CanTurnLeft?: boolean;
|
||||||
|
CanTurnRight?: boolean;
|
||||||
|
DisableCross?: boolean;
|
||||||
|
IsActive?: boolean;
|
||||||
|
SpeedLimit?: number;
|
||||||
|
AliasName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSharpLabel {
|
||||||
|
Id: string;
|
||||||
|
Type: number; // 1
|
||||||
|
Text: string;
|
||||||
|
Position: string;
|
||||||
|
ForeColor: string;
|
||||||
|
BackColor: string;
|
||||||
|
FontFamily?: string;
|
||||||
|
FontSize?: number;
|
||||||
|
FontStyle?: number;
|
||||||
|
Padding?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSharpImage {
|
||||||
|
Id: string;
|
||||||
|
Type: number; // 2
|
||||||
|
Name: string;
|
||||||
|
Position: string;
|
||||||
|
ImagePath?: string;
|
||||||
|
ImageBase64?: string;
|
||||||
|
Scale?: string;
|
||||||
|
Opacity?: number;
|
||||||
|
Rotation?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSharpMagnet {
|
||||||
|
Id: string;
|
||||||
|
Type: number; // 4
|
||||||
|
P1: { X: number; Y: number };
|
||||||
|
P2: { X: number; Y: number };
|
||||||
|
ControlPoint?: { X: number; Y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSharpMark {
|
||||||
|
Id: string;
|
||||||
|
Type: number; // 3
|
||||||
|
Position: string;
|
||||||
|
X: number;
|
||||||
|
Y: number;
|
||||||
|
Rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSharpMapData {
|
||||||
|
Nodes: CSharpNode[];
|
||||||
|
Labels?: CSharpLabel[];
|
||||||
|
Images?: CSharpImage[];
|
||||||
|
Magnets: CSharpMagnet[];
|
||||||
|
Marks: CSharpMark[];
|
||||||
|
Settings?: any;
|
||||||
|
Version: string;
|
||||||
|
CreatedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AgvMotionState {
|
||||||
|
IDLE = 'IDLE',
|
||||||
|
FORWARD = 'FORWARD',
|
||||||
|
BACKWARD = 'BACKWARD',
|
||||||
|
TURN_LEFT = 'TURN_LEFT',
|
||||||
|
TURN_RIGHT = 'TURN_RIGHT',
|
||||||
|
TURN_LEFT_180 = 'TURN_LEFT_180',
|
||||||
|
TURN_RIGHT_180 = 'TURN_RIGHT_180',
|
||||||
|
MARK_STOPPING = 'MARK_STOPPING',
|
||||||
|
RUNNING = 'RUNNING',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgvRunConfig {
|
||||||
|
direction: 'FWD' | 'BWD';
|
||||||
|
branch: 'STRAIGHT' | 'LEFT' | 'RIGHT';
|
||||||
|
speedLevel: 'L' | 'M' | 'H';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Protocol Enums (Matched with C# DevAGV.cs) ---
|
||||||
|
|
||||||
|
export enum AgvError {
|
||||||
|
Emergency = 0,
|
||||||
|
Overcurrent = 1,
|
||||||
|
Charger_run_error = 2,
|
||||||
|
Charger_pos_error = 3,
|
||||||
|
line_out_error = 4,
|
||||||
|
runerror_by_no_magent_line = 5,
|
||||||
|
agv_system_error=6,
|
||||||
|
battery_low_voltage=7,
|
||||||
|
lift_time_over=9,
|
||||||
|
lift_driver_ocr=10,
|
||||||
|
lift_driver_emg = 11,
|
||||||
|
arrive_ctl_comm_error = 12,
|
||||||
|
door_ctl_comm_error = 13,
|
||||||
|
charger_comm_error = 14,
|
||||||
|
cross_ctrl_comm_error = 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AgvSignal {
|
||||||
|
front_gate_out = 0,
|
||||||
|
rear_sensor_out = 1,
|
||||||
|
mark_sensor_1 = 2,
|
||||||
|
mark_sensor_2 = 3,
|
||||||
|
lift_down_sensor = 4,
|
||||||
|
lift_up_sensor = 5,
|
||||||
|
magnet_relay = 6,
|
||||||
|
charger_align_sensor = 7,
|
||||||
|
front_center_sensor = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SystemFlag0 {
|
||||||
|
Memory_RW_State = 5,
|
||||||
|
EXT_IO_Conn_State = 6,
|
||||||
|
RFID_Conn_State = 7,
|
||||||
|
M5E_Module_Run_State = 8,
|
||||||
|
Front_Ultrasonic_Conn_State = 9,
|
||||||
|
Front_Untrasonic_Sensor_State = 10,
|
||||||
|
Side_Ultrasonic_Conn_State = 11,
|
||||||
|
Side_Ultrasonic_Sensor_State = 12,
|
||||||
|
Front_Guide_Sensor_State = 13,
|
||||||
|
Rear_Guide_Sensor_State = 14,
|
||||||
|
Battery_Level_Check = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SystemFlag1 {
|
||||||
|
Side_Detect_Ignore = 3,
|
||||||
|
Melody_check = 4,
|
||||||
|
Mark2_check = 5,
|
||||||
|
Mark1_check = 6,
|
||||||
|
gateout_check = 7,
|
||||||
|
Battery_charging = 8,
|
||||||
|
re_Start = 9,
|
||||||
|
front_detect_ignore = 10,
|
||||||
|
front_detect_check = 11,
|
||||||
|
stop_by_front_detect = 12,
|
||||||
|
stop_by_cross_in = 13,
|
||||||
|
agv_stop = 14,
|
||||||
|
agv_run = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgvState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotation: number;
|
||||||
|
targetRotation: number | null;
|
||||||
|
speed: number;
|
||||||
|
liftHeight: number;
|
||||||
|
motionState: AgvMotionState;
|
||||||
|
runConfig: AgvRunConfig;
|
||||||
|
error: string | null;
|
||||||
|
sensorStatus: string; // Corresponds to sts_sensor in C#
|
||||||
|
|
||||||
|
// Physical Sensors
|
||||||
|
detectedRfid: string | null;
|
||||||
|
sensorLineFront: boolean;
|
||||||
|
sensorLineRear: boolean;
|
||||||
|
sensorMark: boolean;
|
||||||
|
|
||||||
|
// New features
|
||||||
|
magnetOn: boolean;
|
||||||
|
liftStatus: 'IDLE' | 'UP' | 'DOWN';
|
||||||
|
lidarEnabled: boolean; // 1=ON, 0=OFF
|
||||||
|
|
||||||
|
// Protocol Flags (Integers representing bitmaps)
|
||||||
|
system0: number;
|
||||||
|
system1: number;
|
||||||
|
errorFlags: number;
|
||||||
|
signalFlags: number;
|
||||||
|
|
||||||
|
// Battery
|
||||||
|
batteryLevel: number; // Percentage 0-100
|
||||||
|
maxCapacity: number; // Ah (Total Capacity)
|
||||||
|
batteryTemp: number;
|
||||||
|
cellVoltages: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
type: 'INFO' | 'RX' | 'TX' | 'ERROR';
|
||||||
|
source: 'AGV' | 'BMS' | 'ACS' | 'SYSTEM';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcsPacket {
|
||||||
|
id: number;
|
||||||
|
command: number;
|
||||||
|
data: number[];
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user