Initial commit: Refactor AgvAutoRunControls

This commit is contained in:
2025-12-18 23:52:02 +09:00
commit c4089aeb20
25 changed files with 7517 additions and 0 deletions

24
.gitignore vendored Normal file
View 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?

1270
App.tsx Normal file

File diff suppressed because it is too large Load Diff

1219
NewMap.json Normal file

File diff suppressed because it is too large Load Diff

20
README.md Normal file
View 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
View 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;

View 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
View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"description": "Generated by Gemini.",
"requestFramePermissions": [
"serial"
],
"name": "AGV Emulator"
}

1771
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View 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
View 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
View 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
View 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
View 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, '.'),
}
}
};
});