"Initial_commit"
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?
|
||||||
251
App.tsx
Normal file
251
App.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { serialService } from './services/serialService';
|
||||||
|
import { JBDProtocol, CMD_BASIC_INFO, CMD_CELL_INFO, CMD_HW_VERSION } from './services/jbdProtocol';
|
||||||
|
import { BMSBasicInfo, BMSCellInfo, ConnectionState } from './types';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
|
import Settings from './components/Settings';
|
||||||
|
import Terminal from './components/Terminal';
|
||||||
|
import { LayoutDashboard, Settings as SettingsIcon, Usb, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const [connectionState, setConnectionState] = useState<ConnectionState>(ConnectionState.DISCONNECTED);
|
||||||
|
const [basicInfo, setBasicInfo] = useState<BMSBasicInfo | null>(null);
|
||||||
|
const [cellInfo, setCellInfo] = useState<BMSCellInfo | null>(null);
|
||||||
|
const [hwVersion, setHwVersion] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'dashboard' | 'settings'>('dashboard');
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Polling Logic
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
let timeoutId: number;
|
||||||
|
|
||||||
|
const runPoll = async () => {
|
||||||
|
// ONLY POLL IF ON DASHBOARD AND CONNECTED
|
||||||
|
if (connectionState !== ConnectionState.CONNECTED || activeTab !== 'dashboard') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get Basic Info
|
||||||
|
const basicData = await serialService.sendCommand(CMD_BASIC_INFO);
|
||||||
|
const parsedBasic = JBDProtocol.parseBasicInfo(basicData);
|
||||||
|
if (isMounted) setBasicInfo(parsedBasic);
|
||||||
|
|
||||||
|
// Delay 500ms
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 2. Get Cell Info
|
||||||
|
const cellData = await serialService.sendCommand(CMD_CELL_INFO);
|
||||||
|
const parsedCells = JBDProtocol.parseCellInfo(cellData);
|
||||||
|
if (isMounted) setCellInfo(parsedCells);
|
||||||
|
|
||||||
|
// Delay 500ms
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 3. Get Hardware Version (CMD 0x05)
|
||||||
|
const hwData = await serialService.sendCommand(CMD_HW_VERSION);
|
||||||
|
// CMD 0x05 usually returns raw ASCII string in payload
|
||||||
|
const versionStr = new TextDecoder().decode(hwData);
|
||||||
|
if (isMounted) setHwVersion(versionStr);
|
||||||
|
|
||||||
|
if (isMounted) setErrorMsg(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Polling error:", e);
|
||||||
|
// Log to terminal for debugging
|
||||||
|
serialService.log('error', `Poll Fail: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
// Schedule next poll cycle in 500ms
|
||||||
|
if (isMounted && connectionState === ConnectionState.CONNECTED && activeTab === 'dashboard') {
|
||||||
|
timeoutId = window.setTimeout(runPoll, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connectionState === ConnectionState.CONNECTED && activeTab === 'dashboard') {
|
||||||
|
runPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [connectionState, activeTab]);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
try {
|
||||||
|
setConnectionState(ConnectionState.CONNECTING);
|
||||||
|
await serialService.connect();
|
||||||
|
setConnectionState(ConnectionState.CONNECTED);
|
||||||
|
setErrorMsg(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setConnectionState(ConnectionState.ERROR);
|
||||||
|
setErrorMsg(e.message || "시리얼 포트 연결 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await serialService.disconnect();
|
||||||
|
setConnectionState(ConnectionState.DISCONNECTED);
|
||||||
|
setBasicInfo(null);
|
||||||
|
setCellInfo(null);
|
||||||
|
setHwVersion(null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMosfet = async (type: 'charge' | 'discharge', currentState: boolean) => {
|
||||||
|
if (!basicInfo) return;
|
||||||
|
try {
|
||||||
|
const newCharge = type === 'charge' ? !currentState : basicInfo.mosfetStatus.charge;
|
||||||
|
const newDischarge = type === 'discharge' ? !currentState : basicInfo.mosfetStatus.discharge;
|
||||||
|
|
||||||
|
await serialService.toggleMosfet(newCharge, newDischarge);
|
||||||
|
} catch (e: any) {
|
||||||
|
alert("MOSFET 제어 실패: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'dashboard':
|
||||||
|
return <Dashboard basicInfo={basicInfo} cellInfo={cellInfo} hwVersion={hwVersion} onToggleMosfet={toggleMosfet} />;
|
||||||
|
case 'settings':
|
||||||
|
return <Settings />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full bg-gray-950 text-gray-100 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-20 lg:w-64 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col z-20">
|
||||||
|
<div className="p-6 flex items-center justify-center lg:justify-start">
|
||||||
|
<div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center font-bold shadow-lg shadow-blue-900/50">
|
||||||
|
J
|
||||||
|
</div>
|
||||||
|
<span className="ml-3 font-bold text-xl hidden lg:block tracking-tight text-gray-100">JBD Tool</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-4 space-y-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('dashboard')}
|
||||||
|
className={`w-full flex items-center p-3 rounded-xl transition-all ${
|
||||||
|
activeTab === 'dashboard'
|
||||||
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={20} />
|
||||||
|
<span className="ml-3 hidden lg:block font-medium">대시보드</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('settings')}
|
||||||
|
className={`w-full flex items-center p-3 rounded-xl transition-all ${
|
||||||
|
activeTab === 'settings'
|
||||||
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={20} />
|
||||||
|
<span className="ml-3 hidden lg:block font-medium">설정 (EEPROM)</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{connectionState === ConnectionState.CONNECTED ? (
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className="w-full py-3 px-4 bg-red-900/30 text-red-400 border border-red-900/50 rounded-xl hover:bg-red-900/50 transition-colors flex items-center justify-center font-semibold"
|
||||||
|
>
|
||||||
|
<Usb size={18} className="mr-2" />
|
||||||
|
<span className="hidden lg:inline">연결 해제</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connectionState === ConnectionState.CONNECTING}
|
||||||
|
className="w-full py-3 px-4 bg-green-600 text-white rounded-xl hover:bg-green-500 transition-colors flex items-center justify-center font-semibold shadow-lg shadow-green-900/20"
|
||||||
|
>
|
||||||
|
{connectionState === ConnectionState.CONNECTING ? <RefreshCw className="animate-spin" size={18}/> : <Usb size={18} className="mr-2" />}
|
||||||
|
<span className="hidden lg:inline">BMS 연결</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-16 bg-gray-900/50 backdrop-blur border-b border-gray-800 flex items-center justify-between px-6 z-10 flex-shrink-0">
|
||||||
|
<h1 className="text-xl font-semibold text-gray-200">
|
||||||
|
{activeTab === 'dashboard' ? '개요 (Overview)' : '설정 (Configuration)'}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{connectionState === ConnectionState.CONNECTED && (
|
||||||
|
<div className="flex items-center text-xs font-mono text-green-400 bg-green-900/20 px-3 py-1 rounded-full border border-green-900/50">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 mr-2 animate-pulse"></div>
|
||||||
|
CONNECTED
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{connectionState === ConnectionState.DISCONNECTED && (
|
||||||
|
<div className="flex items-center text-xs font-mono text-gray-500 bg-gray-800 px-3 py-1 rounded-full">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-gray-500 mr-2"></div>
|
||||||
|
OFFLINE
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content Body with Right Sidebar */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Main View */}
|
||||||
|
<main className="flex-1 overflow-hidden bg-gray-950 relative">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="bg-red-900/80 text-white px-6 py-2 flex items-center justify-center text-sm font-medium backdrop-blur absolute top-0 left-0 right-0 z-30 animate-in slide-in-from-top-2">
|
||||||
|
<AlertCircle size={16} className="mr-2" />
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{/* Empty State Overlay */}
|
||||||
|
{connectionState !== ConnectionState.CONNECTED && (
|
||||||
|
<div className="absolute inset-0 bg-gray-950/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center p-6 text-center">
|
||||||
|
<div className="w-16 h-16 bg-gray-800 rounded-2xl flex items-center justify-center mb-6 shadow-xl border border-gray-700">
|
||||||
|
<Usb size={32} className="text-gray-500"/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">장치가 연결되지 않았습니다</h2>
|
||||||
|
<p className="text-gray-400 max-w-md mb-8">
|
||||||
|
JBD BMS를 UART-to-USB 어댑터로 연결하여 실시간 상태를 확인하고 설정을 변경하세요.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
className="py-3 px-8 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-xl transition-all shadow-lg shadow-blue-900/30 flex items-center"
|
||||||
|
>
|
||||||
|
<Usb size={18} className="mr-2"/> 장치 연결하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right Terminal Panel */}
|
||||||
|
<aside className="w-80 lg:w-96 border-l border-gray-800 bg-gray-950 flex flex-col flex-shrink-0 z-20">
|
||||||
|
<Terminal />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
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/1_84d5gkFKHawkofL8Rxh-Z2mUpHS5lqw
|
||||||
|
|
||||||
|
## 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`
|
||||||
430
components/Dashboard.tsx
Normal file
430
components/Dashboard.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
|
||||||
|
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||||
|
import { BMSBasicInfo, BMSCellInfo, ProtectionStatus } from '../types';
|
||||||
|
import { LineChart, Line, BarChart, Bar, Cell, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid, ReferenceLine } from 'recharts';
|
||||||
|
import { Activity, Battery, Thermometer, Zap, AlertTriangle, PlayCircle, StopCircle, Download, RotateCw, RefreshCw, Gauge, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
basicInfo: BMSBasicInfo | null;
|
||||||
|
cellInfo: BMSCellInfo | null;
|
||||||
|
hwVersion: string | null;
|
||||||
|
onToggleMosfet: (type: 'charge' | 'discharge', currentState: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981',
|
||||||
|
'#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef',
|
||||||
|
'#f43f5e', '#fbbf24', '#a3e635', '#34d399', '#22d3ee',
|
||||||
|
'#94a3b8'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Updated MetricCard to accept valueClassName for custom styling (e.g., blinking)
|
||||||
|
const MetricCard: React.FC<{ label: string; value: string; unit?: string; icon: React.ReactNode; color?: string; subValue?: string; valueClassName?: string }> = ({ label, value, unit, icon, color = "text-blue-400", subValue, valueClassName }) => (
|
||||||
|
<div className="bg-gray-900 p-4 rounded-xl shadow-lg border border-gray-800 flex flex-col justify-between h-28 relative overflow-hidden group hover:border-gray-700 transition-colors">
|
||||||
|
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity transform scale-150 origin-top-right">
|
||||||
|
{React.isValidElement(icon) && React.cloneElement(icon as React.ReactElement<any>, { size: 48 })}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-start z-10">
|
||||||
|
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider">{label}</span>
|
||||||
|
<div className={`${color} opacity-80`}>{icon}</div>
|
||||||
|
</div>
|
||||||
|
<div className="z-10 mt-auto">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-2xl font-bold font-mono tracking-tight ${valueClassName || 'text-gray-100'}`}>{value}</span>
|
||||||
|
{unit && <span className="text-gray-500 text-xs font-bold">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
{subValue && <div className="text-gray-500 text-xs mt-1 font-mono">{subValue}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MosfetSwitch: React.FC<{ label: string; active: boolean; onClick: () => void; disabled: boolean }> = ({ label, active, onClick, disabled }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex flex-col items-center justify-center p-4 rounded-xl border transition-all duration-200 w-full relative overflow-hidden ${
|
||||||
|
disabled ? 'opacity-50 cursor-not-allowed grayscale' : 'hover:scale-[1.02] active:scale-[0.98]'
|
||||||
|
} ${
|
||||||
|
active
|
||||||
|
? 'bg-green-900/20 border-green-500/50 text-green-400 shadow-[0_0_15px_rgba(34,197,94,0.1)]'
|
||||||
|
: 'bg-red-900/20 border-red-500/50 text-red-400 shadow-[0_0_15px_rgba(239,68,68,0.1)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute inset-0 opacity-10 ${active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||||
|
<span className="font-bold text-2xl mb-1">{active ? 'ON' : 'OFF'}</span>
|
||||||
|
<span className="text-xs uppercase tracking-wider font-semibold opacity-80">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProtectionBadge: React.FC<{ label: string; active: boolean }> = ({ label, active }) => (
|
||||||
|
<div className={`px-2 py-1 rounded text-[10px] font-bold border transition-colors ${active ? 'bg-red-900/50 border-red-500 text-red-400 animate-pulse' : 'bg-gray-800/50 border-gray-700 text-gray-600'}`}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, onToggleMosfet }) => {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const logDataRef = useRef<any[]>([]);
|
||||||
|
const [logCount, setLogCount] = useState(0);
|
||||||
|
const [voltageHistory, setVoltageHistory] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Initialize/Clear history on connect/disconnect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cellInfo) {
|
||||||
|
setVoltageHistory([]);
|
||||||
|
}
|
||||||
|
}, [cellInfo === null]);
|
||||||
|
|
||||||
|
// Update Voltage History
|
||||||
|
useEffect(() => {
|
||||||
|
if (cellInfo) {
|
||||||
|
setVoltageHistory(prev => {
|
||||||
|
const now = new Date();
|
||||||
|
const timeLabel = now.toLocaleTimeString('en-US', { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
|
||||||
|
const newEntry: any = {
|
||||||
|
time: timeLabel,
|
||||||
|
...cellInfo.voltages.reduce((acc, v, i) => ({ ...acc, [`cell${i+1}`]: v }), {})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep approx 60 points (at ~0.5s interval -> ~30s history, user asked for ~1min so increase buffer)
|
||||||
|
// If polling is 500ms, 120 points = 60 seconds
|
||||||
|
const newHistory = [...prev, newEntry];
|
||||||
|
if (newHistory.length > 120) return newHistory.slice(newHistory.length - 120);
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [cellInfo]);
|
||||||
|
|
||||||
|
// Data logging effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRecording && basicInfo) {
|
||||||
|
logDataRef.current.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
voltage: basicInfo.packVoltage,
|
||||||
|
current: basicInfo.current,
|
||||||
|
capacity: basicInfo.remainingCapacity,
|
||||||
|
rsoc: basicInfo.rsoc,
|
||||||
|
maxCell: cellInfo ? Math.max(...cellInfo.voltages) : 0,
|
||||||
|
minCell: cellInfo ? Math.min(...cellInfo.voltages) : 0,
|
||||||
|
temp1: basicInfo.ntcTemps[0] || 0
|
||||||
|
});
|
||||||
|
setLogCount(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}, [basicInfo, isRecording, cellInfo]);
|
||||||
|
|
||||||
|
const toggleRecording = () => {
|
||||||
|
if (isRecording) {
|
||||||
|
// Stop and download
|
||||||
|
const headers = ['Timestamp', 'PackVoltage(V)', 'Current(A)', 'Capacity(Ah)', 'RSOC(%)', 'MaxCell(V)', 'MinCell(V)', 'Temp1(C)'];
|
||||||
|
const csvContent = "data:text/csv;charset=utf-8,"
|
||||||
|
+ headers.join(",") + "\n"
|
||||||
|
+ logDataRef.current.map(row => Object.values(row).join(",")).join("\n");
|
||||||
|
|
||||||
|
const encodedUri = encodeURI(csvContent);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", encodedUri);
|
||||||
|
link.setAttribute("download", `bms_log_${new Date().toISOString()}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
setIsRecording(false);
|
||||||
|
logDataRef.current = [];
|
||||||
|
setLogCount(0);
|
||||||
|
} else {
|
||||||
|
setIsRecording(true);
|
||||||
|
logDataRef.current = [];
|
||||||
|
setLogCount(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe data extraction (Use defaults if null)
|
||||||
|
const voltages = cellInfo?.voltages || [];
|
||||||
|
const cellCount = voltages.length;
|
||||||
|
const maxVoltage = cellCount > 0 ? Math.max(...voltages) : 0;
|
||||||
|
const minVoltage = cellCount > 0 ? Math.min(...voltages) : 0;
|
||||||
|
const avgVoltage = cellCount > 0 ? voltages.reduce((a, b) => a + b, 0) / cellCount : 0;
|
||||||
|
const deltaVoltage = (maxVoltage - minVoltage);
|
||||||
|
|
||||||
|
const power = basicInfo ? basicInfo.packVoltage * basicInfo.current : 0;
|
||||||
|
|
||||||
|
// Data for Bar Chart
|
||||||
|
const cellChartData = voltages.map((v, i) => ({
|
||||||
|
name: `${i + 1}`,
|
||||||
|
voltage: v,
|
||||||
|
isMax: v === maxVoltage && maxVoltage > 0,
|
||||||
|
isMin: v === minVoltage && minVoltage > 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const protections = basicInfo?.protectionStatus || {
|
||||||
|
covp: false, cuvp: false, povp: false, puvp: false,
|
||||||
|
chgot: false, chgut: false, dsgot: false, dsgut: false,
|
||||||
|
chgoc: false, dsgoc: false, sc: false, afe: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const protectionList = [
|
||||||
|
{ key: 'covp', label: 'COVP' }, { key: 'cuvp', label: 'CUVP' },
|
||||||
|
{ key: 'povp', label: 'POVP' }, { key: 'puvp', label: 'PUVP' },
|
||||||
|
{ key: 'chgot', label: 'CHG OT' }, { key: 'chgut', label: 'CHG UT' },
|
||||||
|
{ key: 'dsgot', label: 'DSG OT' }, { key: 'dsgut', label: 'DSG UT' },
|
||||||
|
{ key: 'chgoc', label: 'CHG OC' }, { key: 'dsgoc', label: 'DSG OC' },
|
||||||
|
{ key: 'sc', label: 'SHORT' }, { key: 'afe', label: 'AFE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Logic for blinking red when SOC < 30
|
||||||
|
const isLowSoc = basicInfo && basicInfo.rsoc < 30;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 overflow-y-auto h-full pb-32">
|
||||||
|
|
||||||
|
{/* Top Metrics Row */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Pack Voltage"
|
||||||
|
value={basicInfo ? basicInfo.packVoltage.toFixed(2) : '-.--'}
|
||||||
|
unit="V"
|
||||||
|
icon={<Zap size={20} />}
|
||||||
|
color="text-yellow-400"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Current"
|
||||||
|
value={basicInfo ? basicInfo.current.toFixed(2) : '-.--'}
|
||||||
|
unit="A"
|
||||||
|
icon={<Activity size={20} />}
|
||||||
|
color={basicInfo && basicInfo.current > 0 ? "text-green-400" : basicInfo && basicInfo.current < 0 ? "text-red-400" : "text-gray-400"}
|
||||||
|
subValue={power !== 0 ? `${power.toFixed(0)} W` : undefined}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="SOC"
|
||||||
|
value={basicInfo ? basicInfo.rsoc.toString() : '--'}
|
||||||
|
unit="%"
|
||||||
|
icon={<Battery size={20} />}
|
||||||
|
// If Low SOC: Blink Red Icon, otherwise green/gray
|
||||||
|
color={isLowSoc ? "text-red-500 animate-pulse" : (basicInfo ? "text-green-400" : "text-gray-400")}
|
||||||
|
// If Low SOC: Blink Red Value Text, otherwise default white
|
||||||
|
valueClassName={isLowSoc ? "text-red-500 animate-pulse" : undefined}
|
||||||
|
subValue={basicInfo ? `${basicInfo.remainingCapacity.toFixed(2)} Ah` : undefined}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Avg Cell"
|
||||||
|
value={avgVoltage ? avgVoltage.toFixed(3) : '-.---'}
|
||||||
|
unit="V"
|
||||||
|
icon={<Gauge size={20} />}
|
||||||
|
color="text-blue-400"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Delta"
|
||||||
|
value={deltaVoltage ? (deltaVoltage * 1000).toFixed(0) : '-'}
|
||||||
|
unit="mV"
|
||||||
|
icon={<AlertTriangle size={20} />}
|
||||||
|
color={deltaVoltage > 0.03 ? "text-red-400" : "text-emerald-400"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Cycles"
|
||||||
|
value={basicInfo ? basicInfo.cycleCount.toString() : '-'}
|
||||||
|
unit=""
|
||||||
|
icon={<RotateCw size={20} />}
|
||||||
|
color="text-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* Left Column: Charts & Status */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
{/* Combined Cell Voltage Monitor */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-gray-200 font-bold flex items-center">
|
||||||
|
<Activity className="mr-2 text-blue-500" size={18}/>
|
||||||
|
Cell Monitor (Instant & Trend)
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-4 text-xs font-mono bg-gray-950/50 px-3 py-1 rounded-lg border border-gray-800">
|
||||||
|
<span className="text-red-400 font-bold">Max: {maxVoltage.toFixed(3)}V</span>
|
||||||
|
<div className="w-px bg-gray-700 mx-1"></div>
|
||||||
|
<span className="text-blue-400 font-bold">Min: {minVoltage.toFixed(3)}V</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1. Bar Chart (Instant) */}
|
||||||
|
<div className="h-40 w-full relative">
|
||||||
|
<div className="absolute top-0 left-0 text-[10px] text-gray-500 font-bold uppercase tracking-wider">Instant Voltage</div>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={cellChartData} margin={{ top: 15, right: 5, left: -20, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="name" stroke="#4b5563" tick={{fontSize: 10}} />
|
||||||
|
<YAxis domain={[minVoltage > 0 ? minVoltage - 0.05 : 2.5, maxVoltage > 0 ? maxVoltage + 0.05 : 4.5]} stroke="#4b5563" tick={{fontSize: 10}} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||||
|
contentStyle={{ backgroundColor: '#111827', borderColor: '#374151', color: '#f3f4f6' }}
|
||||||
|
itemStyle={{ color: '#93c5fd' }}
|
||||||
|
formatter={(value: number) => [value.toFixed(3) + ' V', 'Voltage']}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={avgVoltage} stroke="#10b981" strokeDasharray="3 3" />
|
||||||
|
<Bar dataKey="voltage">
|
||||||
|
{cellChartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.isMax ? '#ef4444' : entry.isMin ? '#3b82f6' : '#6366f1'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-gray-800"></div>
|
||||||
|
|
||||||
|
{/* 2. Line Chart (Trend) */}
|
||||||
|
<div className="h-48 w-full relative">
|
||||||
|
<div className="absolute top-0 left-0 text-[10px] text-gray-500 font-bold uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<TrendingUp size={10} /> Voltage History (~1 min)
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={voltageHistory} margin={{ top: 15, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" vertical={false} />
|
||||||
|
{/* Hide XAxis ticks as requested */}
|
||||||
|
<XAxis dataKey="time" hide />
|
||||||
|
<YAxis domain={['auto', 'auto']} stroke="#6b7280" tick={{fontSize: 10}} width={40} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#111827', borderColor: '#374151', color: '#f3f4f6' }}
|
||||||
|
itemStyle={{ padding: 0, fontSize: '11px' }}
|
||||||
|
labelStyle={{ color: '#9ca3af', marginBottom: '0.25rem', fontSize: '11px' }}
|
||||||
|
formatter={(value: number, name: string) => [value.toFixed(3) + ' V', name]}
|
||||||
|
/>
|
||||||
|
{/* Dynamically create thin lines for each cell */}
|
||||||
|
{Array.from({ length: cellCount }).map((_, i) => (
|
||||||
|
<Line
|
||||||
|
key={`line-${i}`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`cell${i+1}`}
|
||||||
|
stroke={COLORS[i % COLORS.length]}
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1}
|
||||||
|
isAnimationActive={false}
|
||||||
|
name={`C${i+1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Protections & Temps */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Protections */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
||||||
|
<h3 className="text-gray-300 font-bold text-sm mb-4 flex items-center">
|
||||||
|
<AlertTriangle className="mr-2 text-yellow-500" size={16}/>
|
||||||
|
Protection Status
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{protectionList.map((p) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<ProtectionBadge key={p.key} label={p.label} active={protections[p.key]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temperatures */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
||||||
|
<h3 className="text-gray-300 font-bold text-sm mb-4 flex items-center">
|
||||||
|
<Thermometer className="mr-2 text-orange-500" size={16}/>
|
||||||
|
Temperatures
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{basicInfo && basicInfo.ntcTemps.length > 0 ? basicInfo.ntcTemps.map((t, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center bg-gray-800/50 p-2 rounded-lg border border-gray-700 min-w-[60px]">
|
||||||
|
<span className="text-xs text-gray-500">NTC {i + 1}</span>
|
||||||
|
<span className="text-lg font-mono font-bold text-gray-200">{t.toFixed(1)}°C</span>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
// Placeholder
|
||||||
|
<div className="flex flex-col items-center bg-gray-800/30 p-2 rounded-lg border border-gray-800 min-w-[60px]">
|
||||||
|
<span className="text-xs text-gray-600">NTC 1</span>
|
||||||
|
<span className="text-lg font-mono font-bold text-gray-600">--.-°C</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Controls & Capacity */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* MOSFET Control */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
||||||
|
<h3 className="text-gray-200 font-bold mb-4 flex items-center">
|
||||||
|
<Zap className="mr-2 text-purple-500" size={18}/>
|
||||||
|
MOSFET Control
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<MosfetSwitch
|
||||||
|
label="Charge"
|
||||||
|
active={basicInfo?.mosfetStatus.charge ?? false}
|
||||||
|
disabled={!basicInfo}
|
||||||
|
onClick={() => onToggleMosfet('charge', basicInfo?.mosfetStatus.charge ?? false)}
|
||||||
|
/>
|
||||||
|
<MosfetSwitch
|
||||||
|
label="Discharge"
|
||||||
|
active={basicInfo?.mosfetStatus.discharge ?? false}
|
||||||
|
disabled={!basicInfo}
|
||||||
|
onClick={() => onToggleMosfet('discharge', basicInfo?.mosfetStatus.discharge ?? false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacity Info */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
||||||
|
<h3 className="text-gray-200 font-bold mb-4 flex items-center">
|
||||||
|
<Battery className="mr-2 text-green-500" size={18}/>
|
||||||
|
Capacity Info
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center pb-2 border-b border-gray-800">
|
||||||
|
<span className="text-gray-400 text-sm">Design Capacity</span>
|
||||||
|
<span className="font-mono">{basicInfo ? basicInfo.fullCapacity.toFixed(2) : '-.--'} Ah</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pb-2 border-b border-gray-800">
|
||||||
|
<span className="text-gray-400 text-sm">Remaining Cap</span>
|
||||||
|
<span className="font-mono text-green-400">{basicInfo ? basicInfo.remainingCapacity.toFixed(2) : '-.--'} Ah</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pb-2 border-b border-gray-800">
|
||||||
|
<span className="text-gray-400 text-sm">Mfg Date</span>
|
||||||
|
<span className="font-mono text-xs">{basicInfo ? basicInfo.productionDate : 'YYYY-MM-DD'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-400 text-sm">Device Ver</span>
|
||||||
|
<span className="font-mono text-xs text-blue-300">{hwVersion || '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logger */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-gray-200 font-bold flex items-center">Data Logger</h3>
|
||||||
|
{isRecording && <span className="text-xs text-red-400 animate-pulse">● REC ({logCount})</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleRecording}
|
||||||
|
disabled={!basicInfo}
|
||||||
|
className={`w-full py-3 rounded-xl flex items-center justify-center font-bold transition-all ${
|
||||||
|
!basicInfo ? 'bg-gray-800 text-gray-500 cursor-not-allowed' :
|
||||||
|
isRecording
|
||||||
|
? 'bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-900/20'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRecording ? <><StopCircle size={18} className="mr-2"/> Stop & Save CSV</> : <><PlayCircle size={18} className="mr-2"/> Start Recording</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
357
components/Settings.tsx
Normal file
357
components/Settings.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { serialService } from '../services/serialService';
|
||||||
|
import { BMSConfig } from '../types';
|
||||||
|
import * as JBD from '../services/jbdProtocol';
|
||||||
|
import { Save, RefreshCw, AlertTriangle, CheckCircle, RotateCw } from 'lucide-react';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
// 초기 상태를 빈 객체로 시작하여 UI는 렌더링되되 값은 비어있도록 함
|
||||||
|
const [config, setConfig] = useState<Partial<BMSConfig>>({});
|
||||||
|
const [busyReg, setBusyReg] = useState<number | null>(null); // 현재 작업 중인 레지스터 (로딩 표시용)
|
||||||
|
const [globalLoading, setGlobalLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 전체 읽기
|
||||||
|
const readAllSettings = async () => {
|
||||||
|
setGlobalLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
try {
|
||||||
|
await serialService.enterFactoryModeRead();
|
||||||
|
|
||||||
|
const p = serialService.readRegister.bind(serialService);
|
||||||
|
const pBytes = serialService.readRegisterBytes.bind(serialService);
|
||||||
|
|
||||||
|
// 병렬 처리는 시리얼 통신에서 꼬일 수 있으므로 순차 처리 권장 (await 사용)
|
||||||
|
// 주요 레지스터 읽기
|
||||||
|
const newConfig: Partial<BMSConfig> = {};
|
||||||
|
|
||||||
|
newConfig.cellOvp = await p(JBD.REG_COVP);
|
||||||
|
newConfig.cellOvpRel = await p(JBD.REG_COVP_REL);
|
||||||
|
newConfig.cellUvp = await p(JBD.REG_CUVP);
|
||||||
|
newConfig.cellUvpRel = await p(JBD.REG_CUVP_REL);
|
||||||
|
newConfig.packOvp = await p(JBD.REG_POVP);
|
||||||
|
newConfig.packOvpRel = await p(JBD.REG_POVP_REL);
|
||||||
|
newConfig.packUvp = await p(JBD.REG_PUVP);
|
||||||
|
newConfig.packUvpRel = await p(JBD.REG_PUVP_REL);
|
||||||
|
|
||||||
|
newConfig.chgOt = await p(JBD.REG_CHG_OT);
|
||||||
|
newConfig.chgOtRel = await p(JBD.REG_CHG_OT_REL);
|
||||||
|
newConfig.chgUt = await p(JBD.REG_CHG_UT);
|
||||||
|
newConfig.chgUtRel = await p(JBD.REG_CHG_UT_REL);
|
||||||
|
newConfig.dsgOt = await p(JBD.REG_DSG_OT);
|
||||||
|
newConfig.dsgOtRel = await p(JBD.REG_DSG_OT_REL);
|
||||||
|
newConfig.dsgUt = await p(JBD.REG_DSG_UT);
|
||||||
|
newConfig.dsgUtRel = await p(JBD.REG_DSG_UT_REL);
|
||||||
|
|
||||||
|
newConfig.covpHigh = await p(JBD.REG_COVP_HIGH);
|
||||||
|
newConfig.cuvpHigh = await p(JBD.REG_CUVP_HIGH);
|
||||||
|
newConfig.funcConfig = await p(JBD.REG_FUNC_CONFIG);
|
||||||
|
newConfig.ntcConfig = await p(JBD.REG_NTC_CONFIG);
|
||||||
|
|
||||||
|
newConfig.balStart = await p(JBD.REG_BAL_START);
|
||||||
|
newConfig.balWindow = await p(JBD.REG_BAL_WINDOW);
|
||||||
|
|
||||||
|
newConfig.designCapacity = await p(JBD.REG_DESIGN_CAP);
|
||||||
|
newConfig.cycleCapacity = await p(JBD.REG_CYCLE_CAP);
|
||||||
|
newConfig.dsgRate = await p(JBD.REG_DSG_RATE);
|
||||||
|
newConfig.cap100 = await p(JBD.REG_CAP_100);
|
||||||
|
newConfig.cap80 = await p(JBD.REG_CAP_80);
|
||||||
|
newConfig.cap60 = await p(JBD.REG_CAP_60);
|
||||||
|
newConfig.cap40 = await p(JBD.REG_CAP_40);
|
||||||
|
newConfig.cap20 = await p(JBD.REG_CAP_20);
|
||||||
|
newConfig.cap0 = await p(JBD.REG_CAP_0);
|
||||||
|
newConfig.fetCtrl = await p(JBD.REG_FET_CTRL);
|
||||||
|
newConfig.ledTimer = await p(JBD.REG_LED_TIMER);
|
||||||
|
|
||||||
|
newConfig.shuntRes = await p(JBD.REG_SHUNT_RES);
|
||||||
|
newConfig.cellCnt = await p(JBD.REG_CELL_CNT);
|
||||||
|
newConfig.cycleCnt = await p(JBD.REG_CYCLE_CNT);
|
||||||
|
newConfig.serialNum = await p(JBD.REG_SERIAL_NUM);
|
||||||
|
|
||||||
|
newConfig.mfgDate = await p(JBD.REG_MFG_DATE);
|
||||||
|
|
||||||
|
const mfgNameRaw = await pBytes(JBD.REG_MFG_NAME);
|
||||||
|
const deviceNameRaw = await pBytes(JBD.REG_DEVICE_NAME);
|
||||||
|
const barcodeRaw = await pBytes(JBD.REG_BARCODE);
|
||||||
|
|
||||||
|
newConfig.mfgName = JBD.JBDProtocol.parseString(mfgNameRaw);
|
||||||
|
newConfig.deviceName = JBD.JBDProtocol.parseString(deviceNameRaw);
|
||||||
|
newConfig.barcode = JBD.JBDProtocol.parseString(barcodeRaw);
|
||||||
|
|
||||||
|
await serialService.exitFactoryMode();
|
||||||
|
|
||||||
|
setConfig(prev => ({ ...prev, ...newConfig }));
|
||||||
|
setSuccessMsg("모든 설정을 불러왔습니다.");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError("설정 읽기 실패: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setGlobalLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 항목 읽기
|
||||||
|
const handleReadSingle = async (reg: number, field: keyof BMSConfig, type: 'int' | 'string' | 'bytes' | 'date' = 'int') => {
|
||||||
|
setBusyReg(reg);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
try {
|
||||||
|
await serialService.enterFactoryModeRead();
|
||||||
|
|
||||||
|
let val: any;
|
||||||
|
if (type === 'int' || type === 'date') { // date reads as int raw
|
||||||
|
val = await serialService.readRegister(reg);
|
||||||
|
} else if (type === 'string' || type === 'bytes') {
|
||||||
|
const bytes = await serialService.readRegisterBytes(reg);
|
||||||
|
if (type === 'string') {
|
||||||
|
val = JBD.JBDProtocol.parseString(bytes);
|
||||||
|
} else {
|
||||||
|
val = bytes; // bytes not fully supported in single row yet unless custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await serialService.exitFactoryMode();
|
||||||
|
|
||||||
|
setConfig(prev => ({ ...prev, [field]: val }));
|
||||||
|
setSuccessMsg("항목을 불러왔습니다.");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError("읽기 실패: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyReg(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 항목 저장
|
||||||
|
const handleSaveSingle = async (reg: number, value: number | string | Uint8Array, type: 'int' | 'string' | 'bytes' | 'date' = 'int') => {
|
||||||
|
setBusyReg(reg);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
try {
|
||||||
|
await serialService.enterFactoryModeWrite();
|
||||||
|
|
||||||
|
if (type === 'int' || type === 'date') {
|
||||||
|
await serialService.writeRegister(reg, value as number);
|
||||||
|
} else if (type === 'string') {
|
||||||
|
const bytes = JBD.JBDProtocol.encodeString(value as string);
|
||||||
|
await serialService.writeRegisterBytes(reg, bytes);
|
||||||
|
} else if (type === 'bytes') {
|
||||||
|
await serialService.writeRegisterBytes(reg, value as Uint8Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
await serialService.exitFactoryMode();
|
||||||
|
setSuccessMsg("설정이 저장되었습니다.");
|
||||||
|
setTimeout(() => setSuccessMsg(null), 2000);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError("저장 실패: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyReg(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 컴포넌트
|
||||||
|
const Group: React.FC<{title: string, children: React.ReactNode}> = ({title, children}) => (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 shadow-xl overflow-hidden mb-6">
|
||||||
|
<div className="bg-gray-800/50 px-5 py-3 border-b border-gray-700">
|
||||||
|
<h3 className="text-lg font-bold text-gray-200 flex items-center gap-2">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-1 space-y-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 개별 설정 행 컴포넌트
|
||||||
|
const SettingRow: React.FC<{
|
||||||
|
label: string;
|
||||||
|
val: number | string | undefined;
|
||||||
|
unit?: string;
|
||||||
|
reg: number;
|
||||||
|
field: keyof BMSConfig;
|
||||||
|
scale?: number;
|
||||||
|
type?: 'int' | 'string' | 'bytes' | 'date';
|
||||||
|
}> = ({ label, val, unit, reg, field, scale = 1, type = 'int' }) => {
|
||||||
|
const [localVal, setLocalVal] = useState<string | number>('');
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const isBusy = busyReg === reg;
|
||||||
|
|
||||||
|
// config 값이 업데이트되면 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (val === undefined || val === null) {
|
||||||
|
setLocalVal('');
|
||||||
|
setIsDirty(false);
|
||||||
|
} else {
|
||||||
|
if (type === 'date') {
|
||||||
|
setLocalVal(JBD.JBDProtocol.parseDate(val as number));
|
||||||
|
} else if (type === 'int') {
|
||||||
|
setLocalVal(Number(val) * scale);
|
||||||
|
} else {
|
||||||
|
setLocalVal(val as string);
|
||||||
|
}
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
}, [val, scale, type]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLocalVal(e.target.value);
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRead = () => {
|
||||||
|
handleReadSingle(reg, field, type as 'int' | 'string' | 'bytes' | 'date');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
let payload: any = localVal;
|
||||||
|
if (type === 'int') {
|
||||||
|
payload = Number(localVal) / scale;
|
||||||
|
} else if (type === 'date') {
|
||||||
|
payload = JBD.JBDProtocol.encodeDate(localVal as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSaveSingle(reg, payload, type as 'int' | 'string' | 'bytes' | 'date');
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-950/50 border-b border-gray-800 last:border-0 hover:bg-gray-900 transition-colors">
|
||||||
|
<div className="flex-1 pr-4">
|
||||||
|
<div className="text-sm text-gray-300 font-medium">{label}</div>
|
||||||
|
<div className="text-xs text-gray-600 font-mono">ADDR: 0x{reg.toString(16).toUpperCase().padStart(2, '0')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={type === 'date' ? 'date' : 'text'}
|
||||||
|
value={localVal}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={val === undefined ? "?" : ""}
|
||||||
|
disabled={isBusy || globalLoading}
|
||||||
|
className={`bg-gray-900 border ${isDirty ? 'border-yellow-600' : 'border-gray-700'} text-gray-100 text-right px-3 py-1.5 rounded focus:outline-none focus:border-blue-500 font-mono h-9 ${type === 'string' ? 'w-40' : 'w-28'}`}
|
||||||
|
/>
|
||||||
|
{unit && <span className="absolute right-2 top-2 text-gray-500 text-xs pointer-events-none opacity-50">{type !== 'date' ? unit : ''}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onRead}
|
||||||
|
disabled={isBusy || globalLoading}
|
||||||
|
title="개별 읽기"
|
||||||
|
className="p-2 bg-gray-800 text-gray-400 hover:text-blue-400 hover:bg-gray-700 rounded-lg transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCw size={16} className={isBusy ? 'animate-spin text-blue-500' : ''} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isBusy || globalLoading}
|
||||||
|
title="개별 저장"
|
||||||
|
className={`p-2 rounded-lg transition-all disabled:opacity-50 ${isDirty ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-gray-800 text-gray-500 hover:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 pb-24 overflow-y-auto h-full max-w-6xl mx-auto">
|
||||||
|
{/* 상단 툴바 */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center mb-6 gap-4 sticky top-0 bg-gray-950/90 backdrop-blur z-20 py-4 border-b border-gray-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-200">EEPROM 설정</h2>
|
||||||
|
<p className="text-gray-400 text-sm">BMS 내부 파라미터를 개별적으로 읽거나 수정할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={readAllSettings}
|
||||||
|
disabled={globalLoading}
|
||||||
|
className="flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-colors shadow-lg shadow-blue-900/20 text-sm font-bold"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={`mr-2 ${globalLoading ? 'animate-spin' : ''}`} />
|
||||||
|
전체 설정 읽기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메시지 영역 */}
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-900/20 border border-red-900/50 rounded-xl text-red-400 flex items-center animate-in slide-in-from-top-2">
|
||||||
|
<AlertTriangle size={20} className="mr-3" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="p-4 bg-green-900/20 border border-green-900/50 rounded-xl text-green-400 flex items-center animate-in slide-in-from-top-2">
|
||||||
|
<CheckCircle size={20} className="mr-3" />
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 그리드 */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Group title="전압 보호 (Voltage Protection)">
|
||||||
|
<SettingRow label="셀 과전압 (Cell OVP)" unit="mV" val={config.cellOvp} reg={JBD.REG_COVP} field="cellOvp" />
|
||||||
|
<SettingRow label="셀 과전압 해제 (Release)" unit="mV" val={config.cellOvpRel} reg={JBD.REG_COVP_REL} field="cellOvpRel" />
|
||||||
|
<SettingRow label="셀 저전압 (Cell UVP)" unit="mV" val={config.cellUvp} reg={JBD.REG_CUVP} field="cellUvp" />
|
||||||
|
<SettingRow label="셀 저전압 해제 (Release)" unit="mV" val={config.cellUvpRel} reg={JBD.REG_CUVP_REL} field="cellUvpRel" />
|
||||||
|
<SettingRow label="팩 과전압 (Pack OVP)" unit="V" val={config.packOvp} reg={JBD.REG_POVP} field="packOvp" scale={0.01} />
|
||||||
|
<SettingRow label="팩 과전압 해제 (Release)" unit="V" val={config.packOvpRel} reg={JBD.REG_POVP_REL} field="packOvpRel" scale={0.01} />
|
||||||
|
<SettingRow label="팩 저전압 (Pack UVP)" unit="V" val={config.packUvp} reg={JBD.REG_PUVP} field="packUvp" scale={0.01} />
|
||||||
|
<SettingRow label="팩 저전압 해제 (Release)" unit="V" val={config.packUvpRel} reg={JBD.REG_PUVP_REL} field="packUvpRel" scale={0.01} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group title="온도 보호 (Temperature)">
|
||||||
|
<SettingRow label="충전 과온 (Chg OT)" unit="°C" val={config.chgOt} reg={JBD.REG_CHG_OT} field="chgOt" type="int" />
|
||||||
|
<SettingRow label="충전 과온 해제 (Release)" unit="°C" val={config.chgOtRel} reg={JBD.REG_CHG_OT_REL} field="chgOtRel" />
|
||||||
|
<SettingRow label="충전 저온 (Chg UT)" unit="°C" val={config.chgUt} reg={JBD.REG_CHG_UT} field="chgUt" />
|
||||||
|
<SettingRow label="충전 저온 해제 (Release)" unit="°C" val={config.chgUtRel} reg={JBD.REG_CHG_UT_REL} field="chgUtRel" />
|
||||||
|
<SettingRow label="방전 과온 (Dsg OT)" unit="°C" val={config.dsgOt} reg={JBD.REG_DSG_OT} field="dsgOt" />
|
||||||
|
<SettingRow label="방전 과온 해제 (Release)" unit="°C" val={config.dsgOtRel} reg={JBD.REG_DSG_OT_REL} field="dsgOtRel" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group title="하드웨어 정보 (Hardware Info)">
|
||||||
|
<SettingRow label="제조일자 (Mfg Date)" unit="" val={config.mfgDate} reg={JBD.REG_MFG_DATE} field="mfgDate" type="date" />
|
||||||
|
<SettingRow label="제조사명 (Mfg Name)" unit="" val={config.mfgName} reg={JBD.REG_MFG_NAME} field="mfgName" type="string" />
|
||||||
|
<SettingRow label="장치명 (Device Name)" unit="" val={config.deviceName} reg={JBD.REG_DEVICE_NAME} field="deviceName" type="string" />
|
||||||
|
<SettingRow label="바코드 (Barcode)" unit="" val={config.barcode} reg={JBD.REG_BARCODE} field="barcode" type="string" />
|
||||||
|
<SettingRow label="션트 저항 (Shunt)" unit="mΩ" val={config.shuntRes} reg={JBD.REG_SHUNT_RES} field="shuntRes" scale={0.1} />
|
||||||
|
<SettingRow label="셀 개수 (Cell Count)" unit="S" val={config.cellCnt} reg={JBD.REG_CELL_CNT} field="cellCnt" />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Group title="용량 설정 (Capacity Settings)">
|
||||||
|
<SettingRow label="설계 용량 (Design Cap)" unit="Ah" val={config.designCapacity} reg={JBD.REG_DESIGN_CAP} field="designCapacity" scale={0.01} />
|
||||||
|
<SettingRow label="사이클 용량 (Cycle Cap)" unit="Ah" val={config.cycleCapacity} reg={JBD.REG_CYCLE_CAP} field="cycleCapacity" scale={0.01} />
|
||||||
|
<SettingRow label="방전율 (Dsg Rate)" unit="%" val={config.dsgRate} reg={JBD.REG_DSG_RATE} field="dsgRate" scale={0.1} />
|
||||||
|
<SettingRow label="100% 전압 (Vol at 100%)" unit="mV" val={config.cap100} reg={JBD.REG_CAP_100} field="cap100" />
|
||||||
|
<SettingRow label="80% 전압 (Vol at 80%)" unit="mV" val={config.cap80} reg={JBD.REG_CAP_80} field="cap80" />
|
||||||
|
<SettingRow label="60% 전압 (Vol at 60%)" unit="mV" val={config.cap60} reg={JBD.REG_CAP_60} field="cap60" />
|
||||||
|
<SettingRow label="40% 전압 (Vol at 40%)" unit="mV" val={config.cap40} reg={JBD.REG_CAP_40} field="cap40" />
|
||||||
|
<SettingRow label="20% 전압 (Vol at 20%)" unit="mV" val={config.cap20} reg={JBD.REG_CAP_20} field="cap20" />
|
||||||
|
<SettingRow label="0% 전압 (Vol at 0%)" unit="mV" val={config.cap0} reg={JBD.REG_CAP_0} field="cap0" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group title="밸런스 설정 (Balancer)">
|
||||||
|
<SettingRow label="시작 전압 (Start Volt)" unit="mV" val={config.balStart} reg={JBD.REG_BAL_START} field="balStart" />
|
||||||
|
<SettingRow label="정밀도 (Precision)" unit="mV" val={config.balWindow} reg={JBD.REG_BAL_WINDOW} field="balWindow" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group title="기타 설정 (Misc)">
|
||||||
|
<SettingRow label="기능 설정 (Func Config)" unit="HEX" val={config.funcConfig} reg={JBD.REG_FUNC_CONFIG} field="funcConfig" />
|
||||||
|
<SettingRow label="NTC 설정 (NTC Config)" unit="HEX" val={config.ntcConfig} reg={JBD.REG_NTC_CONFIG} field="ntcConfig" />
|
||||||
|
<SettingRow label="FET 제어 시간" unit="s" val={config.fetCtrl} reg={JBD.REG_FET_CTRL} field="fetCtrl" />
|
||||||
|
<SettingRow label="LED 타이머" unit="s" val={config.ledTimer} reg={JBD.REG_LED_TIMER} field="ledTimer" />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
151
components/Terminal.tsx
Normal file
151
components/Terminal.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { serialService } from '../services/serialService';
|
||||||
|
import { Terminal as TerminalIcon, Send, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
type: 'tx' | 'rx' | 'info' | 'error';
|
||||||
|
timestamp: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Terminal: React.FC = () => {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const MAX_LOGS = 200;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Subscribe to serial service logs
|
||||||
|
serialService.setLogCallback((type, data) => {
|
||||||
|
setLogs(prev => {
|
||||||
|
const newLog: LogEntry = {
|
||||||
|
id: Date.now() + Math.random(), // Ensure unique key for fast updates
|
||||||
|
type,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
|
||||||
|
data
|
||||||
|
};
|
||||||
|
// NEWEST FIRST: Prepend new log to the beginning of the array
|
||||||
|
const newLogs = [newLog, ...prev];
|
||||||
|
|
||||||
|
if (newLogs.length > MAX_LOGS) {
|
||||||
|
return newLogs.slice(0, MAX_LOGS);
|
||||||
|
}
|
||||||
|
return newLogs;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
serialService.setLogCallback(null);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If auto-scroll is on, force scroll to top when logs change
|
||||||
|
if (autoScroll && logsContainerRef.current) {
|
||||||
|
logsContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [logs, autoScroll]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
|
||||||
|
// Remove spaces and validate hex
|
||||||
|
const hexStr = input.replace(/\s+/g, '');
|
||||||
|
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) {
|
||||||
|
// Local error log (prepended)
|
||||||
|
setLogs(prev => [{
|
||||||
|
id: Date.now(),
|
||||||
|
type: 'error',
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
data: '유효하지 않은 HEX 포맷입니다.'
|
||||||
|
}, ...prev]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serialService.sendRaw(bytes);
|
||||||
|
// readRaw also triggers callback inside service
|
||||||
|
await serialService.readRaw();
|
||||||
|
} catch (e: any) {
|
||||||
|
// Errors from service are logged via callback usually
|
||||||
|
}
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-950/50">
|
||||||
|
<div className="flex justify-between items-center p-3 border-b border-gray-800 bg-gray-900/50">
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<TerminalIcon size={18} className="text-gray-400 shrink-0" />
|
||||||
|
<span className="font-semibold text-gray-300 truncate">터미널 / Logs</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoScroll(!autoScroll)}
|
||||||
|
className={`p-1.5 rounded transition-colors ${autoScroll ? 'text-green-400 bg-green-900/20' : 'text-gray-500 hover:text-gray-300'}`}
|
||||||
|
title={autoScroll ? "Auto-scroll: ON (Top)" : "Auto-scroll: OFF"}
|
||||||
|
>
|
||||||
|
{/* Icon indicates scroll direction focus */}
|
||||||
|
<ArrowUp size={16} className={autoScroll ? "" : "opacity-50"} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLogs([])} className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded transition-colors" title="Clear Logs">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-black/50 overflow-hidden flex flex-col font-mono text-xs shadow-inner">
|
||||||
|
{/* Input Area (Top) or Bottom? Keeping input at bottom is standard, logs flow down from top */}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={logsContainerRef}
|
||||||
|
className="flex-1 overflow-y-auto p-2 space-y-1 scroll-smooth"
|
||||||
|
>
|
||||||
|
{logs.length === 0 && <div className="text-gray-700 text-center mt-4 italic">대기 중... (Logs)</div>}
|
||||||
|
|
||||||
|
{logs.map(log => (
|
||||||
|
<div key={log.id} className="flex gap-2 animate-in fade-in slide-in-from-top-1 hover:bg-gray-900/30 p-0.5 rounded border-b border-gray-900/50 last:border-0">
|
||||||
|
<span className="text-gray-600 shrink-0 select-none">[{log.timestamp}]</span>
|
||||||
|
<div className="break-all flex-1">
|
||||||
|
{log.type === 'tx' && <span className="text-blue-400 font-bold mr-1">TX</span>}
|
||||||
|
{log.type === 'rx' && <span className="text-green-400 font-bold mr-1">RX</span>}
|
||||||
|
{log.type === 'error' && <span className="text-red-500 font-bold mr-1">ERR</span>}
|
||||||
|
{log.type === 'info' && <span className="text-gray-400 font-bold mr-1">INF</span>}
|
||||||
|
<span className={`${log.type === 'error' ? 'text-red-400' : 'text-gray-300'}`}>
|
||||||
|
{log.data}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-gray-800 bg-gray-900/30">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
|
placeholder="HEX (e.g. DD A5 03...)"
|
||||||
|
className="flex-1 bg-gray-950 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-blue-500 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded flex items-center gap-1 transition-colors text-xs font-bold"
|
||||||
|
>
|
||||||
|
<Send size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Terminal;
|
||||||
57
index.html
Normal file
57
index.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>JBD BMS Web Tool</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
gray: {
|
||||||
|
850: '#1f2937',
|
||||||
|
900: '#111827',
|
||||||
|
950: '#030712',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Custom scrollbar for webkit */
|
||||||
|
::-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": {
|
||||||
|
"lucide-react": "https://esm.sh/lucide-react@^0.561.0",
|
||||||
|
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||||
|
"react/": "https://esm.sh/react@^19.2.3/",
|
||||||
|
"react": "https://esm.sh/react@^19.2.3",
|
||||||
|
"recharts": "https://esm.sh/recharts@^3.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-950 text-gray-100 font-sans antialiased overflow-hidden h-screen w-screen">
|
||||||
|
<div id="root" class="h-full w-full"></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 @@
|
|||||||
|
{
|
||||||
|
"name": "JBD BMS Web Tool",
|
||||||
|
"description": "A web-based interface for JBD Battery Management Systems using the Web Serial API. Monitor cell voltages, control MOSFETs, and configure settings directly from your browser.",
|
||||||
|
"requestFramePermissions": [
|
||||||
|
"serial"
|
||||||
|
]
|
||||||
|
}
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "jbd-bms-web-tool",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.561.0",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"recharts": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
250
services/jbdProtocol.ts
Normal file
250
services/jbdProtocol.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
|
||||||
|
import { BMSBasicInfo, BMSCellInfo, ProtectionStatus } from '../types';
|
||||||
|
|
||||||
|
export const START_BYTE = 0xDD;
|
||||||
|
export const READ_CMD = 0xA5;
|
||||||
|
export const WRITE_CMD = 0x5A;
|
||||||
|
export const END_BYTE = 0x77;
|
||||||
|
|
||||||
|
export const CMD_BASIC_INFO = 0x03;
|
||||||
|
export const CMD_CELL_INFO = 0x04;
|
||||||
|
export const CMD_HW_VERSION = 0x05;
|
||||||
|
export const CMD_MOSFET_CTRL = 0xE1;
|
||||||
|
export const CMD_ENTER_FACTORY = 0x00;
|
||||||
|
export const CMD_EXIT_FACTORY = 0x01;
|
||||||
|
|
||||||
|
// EEPROM Registers
|
||||||
|
export const REG_COVP = 0x24;
|
||||||
|
export const REG_COVP_REL = 0x25;
|
||||||
|
export const REG_CUVP = 0x26;
|
||||||
|
export const REG_CUVP_REL = 0x27;
|
||||||
|
export const REG_POVP = 0x20;
|
||||||
|
export const REG_POVP_REL = 0x21;
|
||||||
|
export const REG_PUVP = 0x22;
|
||||||
|
export const REG_PUVP_REL = 0x23;
|
||||||
|
|
||||||
|
export const REG_CHG_OT = 0x18;
|
||||||
|
export const REG_CHG_OT_REL = 0x19;
|
||||||
|
export const REG_CHG_UT = 0x1A;
|
||||||
|
export const REG_CHG_UT_REL = 0x1B;
|
||||||
|
export const REG_DSG_OT = 0x1C;
|
||||||
|
export const REG_DSG_OT_REL = 0x1D;
|
||||||
|
export const REG_DSG_UT = 0x1E;
|
||||||
|
export const REG_DSG_UT_REL = 0x1F;
|
||||||
|
|
||||||
|
export const REG_CELL_V_DELAYS = 0x3D;
|
||||||
|
export const REG_PACK_V_DELAYS = 0x3C;
|
||||||
|
export const REG_CHG_T_DELAYS = 0x3A;
|
||||||
|
export const REG_DSG_T_DELAYS = 0x3B;
|
||||||
|
export const REG_CHG_OC_DELAYS = 0x3E;
|
||||||
|
export const REG_DSG_OC_DELAYS = 0x3F;
|
||||||
|
|
||||||
|
export const REG_COVP_HIGH = 0x36;
|
||||||
|
export const REG_CUVP_HIGH = 0x37;
|
||||||
|
export const REG_FUNC_CONFIG = 0x2D;
|
||||||
|
export const REG_NTC_CONFIG = 0x2E;
|
||||||
|
export const REG_BAL_START = 0x2A;
|
||||||
|
export const REG_BAL_WINDOW = 0x2B;
|
||||||
|
|
||||||
|
export const REG_DESIGN_CAP = 0x10;
|
||||||
|
export const REG_CYCLE_CAP = 0x11;
|
||||||
|
export const REG_DSG_RATE = 0x14;
|
||||||
|
export const REG_CAP_100 = 0x12;
|
||||||
|
export const REG_CAP_80 = 0x32;
|
||||||
|
export const REG_CAP_60 = 0x33;
|
||||||
|
export const REG_CAP_40 = 0x34;
|
||||||
|
export const REG_CAP_20 = 0x35;
|
||||||
|
export const REG_CAP_0 = 0x13;
|
||||||
|
export const REG_FET_CTRL = 0x30;
|
||||||
|
export const REG_LED_TIMER = 0x31;
|
||||||
|
|
||||||
|
export const REG_SHUNT_RES = 0x2C;
|
||||||
|
export const REG_CELL_CNT = 0x2F;
|
||||||
|
export const REG_CYCLE_CNT = 0x17;
|
||||||
|
export const REG_SERIAL_NUM = 0x16;
|
||||||
|
export const REG_MFG_DATE = 0x15;
|
||||||
|
|
||||||
|
export const REG_MFG_NAME = 0xA0;
|
||||||
|
export const REG_DEVICE_NAME = 0xA1;
|
||||||
|
export const REG_BARCODE = 0xA2;
|
||||||
|
|
||||||
|
|
||||||
|
export class JBDProtocol {
|
||||||
|
|
||||||
|
public static calculateChecksum(payload: Uint8Array): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < payload.length; i++) {
|
||||||
|
sum += payload[i];
|
||||||
|
}
|
||||||
|
return (0x10000 - sum) & 0xFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createPacket(command: number, data: Uint8Array = new Uint8Array(0), mode: number = READ_CMD): Uint8Array {
|
||||||
|
const payloadLength = data.length;
|
||||||
|
const packet = new Uint8Array(7 + payloadLength);
|
||||||
|
packet[0] = START_BYTE;
|
||||||
|
packet[1] = mode;
|
||||||
|
packet[2] = command;
|
||||||
|
packet[3] = payloadLength;
|
||||||
|
|
||||||
|
packet.set(data, 4);
|
||||||
|
|
||||||
|
const checksumPayload = packet.slice(2, 4 + payloadLength);
|
||||||
|
const checksum = this.calculateChecksum(checksumPayload);
|
||||||
|
|
||||||
|
packet[4 + payloadLength] = (checksum >> 8) & 0xFF;
|
||||||
|
packet[4 + payloadLength + 1] = checksum & 0xFF;
|
||||||
|
packet[4 + payloadLength + 2] = END_BYTE;
|
||||||
|
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseResponse(data: Uint8Array): { payload: Uint8Array | null, error?: string } {
|
||||||
|
if (data.length < 7) return { payload: null, error: "Too short" };
|
||||||
|
if (data[0] !== START_BYTE) return { payload: null, error: "Invalid Start Byte" };
|
||||||
|
if (data[data.length - 1] !== END_BYTE) return { payload: null, error: "Invalid End Byte" };
|
||||||
|
|
||||||
|
// Standard length check: data[3] should match internal payload length
|
||||||
|
// But some BMS versions send 0x00 at data[3] even with data present.
|
||||||
|
// So we rely on the ACTUAL packet size passed to us to determine payload.
|
||||||
|
// Structure: [DD] [Mode] [Cmd] [Len] [DATA...] [ChkH] [ChkL] [77]
|
||||||
|
// Indices: 0 1 2 3 4... N-3 N-2 N-1
|
||||||
|
|
||||||
|
// Checksum covers from Cmd (index 2) to end of Data (index N-3 inclusive)
|
||||||
|
// Checksum values are at N-3 (High) and N-2 (Low)
|
||||||
|
|
||||||
|
const endOfDataIndex = data.length - 3;
|
||||||
|
const checksumPayload = data.slice(2, endOfDataIndex);
|
||||||
|
const calculatedChecksum = this.calculateChecksum(checksumPayload);
|
||||||
|
const receivedChecksum = (data[data.length - 3] << 8) | data[data.length - 2];
|
||||||
|
|
||||||
|
if (calculatedChecksum !== receivedChecksum && receivedChecksum != 0) {
|
||||||
|
// NOTE: We return the payload even on checksum error for debugging purposes if needed,
|
||||||
|
// or strictly enforce it.
|
||||||
|
// For now, strict enforcement but detailed error.
|
||||||
|
// However, seeing the user log, if checksum logic on hardware is weird, we might need lax mode.
|
||||||
|
// Based on the log provided:
|
||||||
|
// Data: 03 00 00 0A ... A5
|
||||||
|
// User Checksum: 00 00
|
||||||
|
// If the hardware puts 00 00 checksum, it might be ignoring checksum.
|
||||||
|
|
||||||
|
if (receivedChecksum === 0 && calculatedChecksum !== 0) {
|
||||||
|
// Some clones send 0 checksum? Let's treat it as a warning but allow it?
|
||||||
|
// No, let's report error.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: null,
|
||||||
|
error: `CS Mismatch (Calc:${calculatedChecksum.toString(16).toUpperCase()} Recv:${receivedChecksum.toString(16).toUpperCase()})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload is from index 4 to endOfDataIndex
|
||||||
|
return { payload: data.slice(4, endOfDataIndex) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseString(payload: Uint8Array): string {
|
||||||
|
if (payload.length === 0) return "";
|
||||||
|
const len = payload[0];
|
||||||
|
if (payload.length < 1 + len) return "";
|
||||||
|
const strBytes = payload.slice(1, 1 + len);
|
||||||
|
return new TextDecoder().decode(strBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static encodeString(str: string, maxLen: number = 31): Uint8Array {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(str);
|
||||||
|
const len = Math.min(bytes.length, maxLen);
|
||||||
|
const result = new Uint8Array(len + 1);
|
||||||
|
result[0] = len;
|
||||||
|
result.set(bytes.slice(0, len), 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseDate(val: number): string {
|
||||||
|
const year = (val >> 9) + 2000;
|
||||||
|
const month = (val >> 5) & 0x0F;
|
||||||
|
const day = val & 0x1F;
|
||||||
|
return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static encodeDate(dateStr: string): number {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = d.getMonth() + 1;
|
||||||
|
const day = d.getDate();
|
||||||
|
return ((year - 2000) << 9) | ((month & 0x0F) << 5) | (day & 0x1F);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseBasicInfo(payload: Uint8Array): BMSBasicInfo {
|
||||||
|
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||||
|
const packVoltage = view.getUint16(0, false) / 100;
|
||||||
|
const current = view.getInt16(2, false) / 100;
|
||||||
|
const remainingCapacity = view.getUint16(4, false) / 100;
|
||||||
|
const fullCapacity = view.getUint16(6, false) / 100;
|
||||||
|
const cycleCount = view.getUint16(8, false);
|
||||||
|
const productionDateInt = view.getUint16(10, false);
|
||||||
|
const productionDate = this.parseDate(productionDateInt);
|
||||||
|
|
||||||
|
const balanceStatus = view.getUint16(12, false);
|
||||||
|
const balanceStatusHigh = view.getUint16(14, false);
|
||||||
|
const fullBalance = balanceStatus | (balanceStatusHigh << 16);
|
||||||
|
|
||||||
|
const protectionStatusRaw = view.getUint16(16, false);
|
||||||
|
const protectionStatus = this.parseProtectionStatus(protectionStatusRaw);
|
||||||
|
|
||||||
|
const version = view.getUint8(18);
|
||||||
|
const rsoc = view.getUint8(19);
|
||||||
|
|
||||||
|
const mosfetRaw = view.getUint8(20);
|
||||||
|
const mosfetStatus = {
|
||||||
|
charge: (mosfetRaw & 1) === 1,
|
||||||
|
discharge: ((mosfetRaw >> 1) & 1) === 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const ntcCount = view.getUint8(22);
|
||||||
|
const ntcTemps: number[] = [];
|
||||||
|
let offset = 23;
|
||||||
|
for(let i=0; i<ntcCount; i++) {
|
||||||
|
// Safety check for buffer overflow
|
||||||
|
if (offset + 1 >= payload.byteLength) break;
|
||||||
|
const rawTemp = view.getUint16(offset, false);
|
||||||
|
ntcTemps.push((rawTemp - 2731) / 10);
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packVoltage, current, remainingCapacity, fullCapacity, cycleCount,
|
||||||
|
productionDate, balanceStatus: fullBalance, protectionStatus, version,
|
||||||
|
rsoc, mosfetStatus, ntcCount, ntcTemps
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseCellInfo(payload: Uint8Array): BMSCellInfo {
|
||||||
|
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||||
|
const voltages: number[] = [];
|
||||||
|
for (let i = 0; i < payload.length; i += 2) {
|
||||||
|
if (i + 1 < payload.length) {
|
||||||
|
voltages.push(view.getUint16(i, false) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { voltages };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseProtectionStatus(raw: number): ProtectionStatus {
|
||||||
|
return {
|
||||||
|
covp: !!(raw & 1),
|
||||||
|
cuvp: !!((raw >> 1) & 1),
|
||||||
|
povp: !!((raw >> 2) & 1),
|
||||||
|
puvp: !!((raw >> 3) & 1),
|
||||||
|
chgot: !!((raw >> 4) & 1),
|
||||||
|
chgut: !!((raw >> 5) & 1),
|
||||||
|
dsgot: !!((raw >> 6) & 1),
|
||||||
|
dsgut: !!((raw >> 7) & 1),
|
||||||
|
chgoc: !!((raw >> 8) & 1),
|
||||||
|
dsgoc: !!((raw >> 9) & 1),
|
||||||
|
sc: !!((raw >> 10) & 1),
|
||||||
|
afe: !!((raw >> 11) & 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
335
services/serialService.ts
Normal file
335
services/serialService.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
|
||||||
|
import { JBDProtocol, READ_CMD, WRITE_CMD, CMD_ENTER_FACTORY, CMD_EXIT_FACTORY } from './jbdProtocol';
|
||||||
|
|
||||||
|
type LogType = 'tx' | 'rx' | 'info' | 'error';
|
||||||
|
type LogCallback = (type: LogType, message: string) => void;
|
||||||
|
|
||||||
|
export class SerialService {
|
||||||
|
private port: any | null = null;
|
||||||
|
private reader: ReadableStreamDefaultReader | null = null;
|
||||||
|
private transportLock: Promise<void> = Promise.resolve();
|
||||||
|
private logCallback: LogCallback | null = null;
|
||||||
|
|
||||||
|
public setLogCallback(callback: LogCallback | null) {
|
||||||
|
this.logCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public log(type: LogType, message: string) {
|
||||||
|
if (this.logCallback) {
|
||||||
|
this.logCallback(type, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toHex(data: Uint8Array): string {
|
||||||
|
return Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
if (!('serial' in navigator)) {
|
||||||
|
throw new Error('Web Serial API not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.port && this.port.writable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const port = await navigator.serial.requestPort();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await port.open({ baudRate: 9600 });
|
||||||
|
this.port = port;
|
||||||
|
this.log('info', 'Serial port connected');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Serial port open failed:", e);
|
||||||
|
this.log('error', 'Connection failed: ' + e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.transportLock;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Transport lock error during disconnect:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reader) {
|
||||||
|
try {
|
||||||
|
await this.reader.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Reader cancel failed:", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.reader.releaseLock();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Reader releaseLock failed:", e);
|
||||||
|
}
|
||||||
|
this.reader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.port) {
|
||||||
|
try {
|
||||||
|
await this.port.close();
|
||||||
|
this.log('info', 'Serial port disconnected');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Port close failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.port = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enterFactoryModeRead(): Promise<void> {
|
||||||
|
await this.writeRegisterRaw(0x00, new Uint8Array([0x56, 0x78]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enterFactoryModeWrite(): Promise<void> {
|
||||||
|
await this.writeRegisterRaw(0x01, new Uint8Array([0x28, 0x28]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async exitFactoryMode(): Promise<void> {
|
||||||
|
await this.writeRegisterRaw(0x01, new Uint8Array([0x00, 0x00]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(command: number, data: Uint8Array = new Uint8Array(0), mode: number = READ_CMD): Promise<Uint8Array> {
|
||||||
|
const result = this.transportLock.then(() => this.executeCommandUnsafe(command, data, mode));
|
||||||
|
this.transportLock = result.then(() => {}).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendRaw(data: Uint8Array): Promise<void> {
|
||||||
|
const result = this.transportLock.then(async () => {
|
||||||
|
if (!this.port || !this.port.writable) throw new Error('Port not open');
|
||||||
|
|
||||||
|
this.log('tx', this.toHex(data));
|
||||||
|
|
||||||
|
const writer = this.port.writable.getWriter();
|
||||||
|
try {
|
||||||
|
await writer.write(data);
|
||||||
|
} finally {
|
||||||
|
writer.releaseLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.transportLock = result.then(() => {}).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readRaw(): Promise<Uint8Array> {
|
||||||
|
return this.transportLock.then(async () => {
|
||||||
|
if (!this.port || !this.port.readable) throw new Error('Port not readable');
|
||||||
|
const reader = this.port.readable.getReader();
|
||||||
|
this.reader = reader;
|
||||||
|
try {
|
||||||
|
const timeoutMs = 2000;
|
||||||
|
const timerId = setTimeout(() => reader.cancel(), timeoutMs);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await reader.read();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes('abort')) {
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return new Uint8Array(0);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value, done } = result;
|
||||||
|
|
||||||
|
if (done && !value) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
this.log('rx', this.toHex(value));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Uint8Array(0);
|
||||||
|
} finally {
|
||||||
|
this.reader = null;
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readRegister(reg: number): Promise<number> {
|
||||||
|
const data = await this.sendCommand(reg, new Uint8Array(0), READ_CMD);
|
||||||
|
if (data.length < 2) return 0;
|
||||||
|
return (data[0] << 8) | data[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readRegisterBytes(reg: number): Promise<Uint8Array> {
|
||||||
|
return await this.sendCommand(reg, new Uint8Array(0), READ_CMD);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeRegister(reg: number, value: number): Promise<void> {
|
||||||
|
const data = new Uint8Array([(value >> 8) & 0xFF, value & 0xFF]);
|
||||||
|
await this.sendCommand(reg, data, WRITE_CMD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeRegisterRaw(reg: number, data: Uint8Array): Promise<void> {
|
||||||
|
await this.sendCommand(reg, data, WRITE_CMD);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeRegisterBytes(reg: number, data: Uint8Array): Promise<void> {
|
||||||
|
await this.sendCommand(reg, data, WRITE_CMD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeCommandUnsafe(command: number, data: Uint8Array, mode: number): Promise<Uint8Array> {
|
||||||
|
if (!this.port || !this.port.writable) throw new Error('Port not open');
|
||||||
|
|
||||||
|
const packet = JBDProtocol.createPacket(command, data, mode);
|
||||||
|
|
||||||
|
// LOG TX
|
||||||
|
this.log('tx', this.toHex(packet));
|
||||||
|
|
||||||
|
const writer = this.port.writable.getWriter();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writer.write(packet);
|
||||||
|
} finally {
|
||||||
|
writer.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.readResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readResponse(): Promise<Uint8Array> {
|
||||||
|
if (!this.port || !this.port.readable) throw new Error('Port not readable');
|
||||||
|
|
||||||
|
const reader = this.port.readable.getReader();
|
||||||
|
this.reader = reader;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let buffer: number[] = [];
|
||||||
|
const timeoutMs = 1500;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const elapsedTime = Date.now() - startTime;
|
||||||
|
const remainingTime = timeoutMs - elapsedTime;
|
||||||
|
|
||||||
|
if (remainingTime <= 0) throw new Error('Read timeout');
|
||||||
|
|
||||||
|
const timerId = setTimeout(() => reader.cancel(), remainingTime);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await reader.read();
|
||||||
|
} catch (e) {
|
||||||
|
if (Date.now() - startTime >= timeoutMs) {
|
||||||
|
throw new Error('Read timeout');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value, done } = result;
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
if (Date.now() - startTime >= timeoutMs) {
|
||||||
|
throw new Error('Read timeout');
|
||||||
|
}
|
||||||
|
throw new Error('Stream closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
this.log('rx', this.toHex(value));
|
||||||
|
for(let byte of value) buffer.push(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Buffer
|
||||||
|
while (true) {
|
||||||
|
const startIndex = buffer.indexOf(0xDD);
|
||||||
|
if (startIndex === -1) {
|
||||||
|
// No start byte, clear useless buffer but keep potential partials if needed?
|
||||||
|
// Actually safer to just clear if no DD found at all and buffer is huge.
|
||||||
|
if (buffer.length > 200) buffer.length = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up bytes before start index
|
||||||
|
if (startIndex > 0) {
|
||||||
|
buffer.splice(0, startIndex);
|
||||||
|
continue; // Re-evaluate
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have DD at 0.
|
||||||
|
if (buffer.length < 4) break; // Need more data for basic header
|
||||||
|
|
||||||
|
// Standard logic: length is at index 3
|
||||||
|
const declaredLen = buffer[3];
|
||||||
|
const standardPacketLen = declaredLen + 7;
|
||||||
|
|
||||||
|
let packetFound = false;
|
||||||
|
let packetLen = 0;
|
||||||
|
|
||||||
|
// Strategy 1: Trust declared length if it makes sense and ends with 0x77
|
||||||
|
if (declaredLen > 0 && buffer.length >= standardPacketLen) {
|
||||||
|
if (buffer[standardPacketLen - 1] === 0x77) {
|
||||||
|
packetLen = standardPacketLen;
|
||||||
|
packetFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: If declared length is 0 (anomaly) or Strategy 1 failed (bad length byte),
|
||||||
|
// scan for the next 0x77 to find the packet boundary.
|
||||||
|
if (!packetFound) {
|
||||||
|
// Start searching for 0x77 after the header (index 3)
|
||||||
|
// Minimum packet size is 7 bytes (DD Cmd Status Len ChkH ChkL 77)
|
||||||
|
for (let i = 6; i < buffer.length; i++) {
|
||||||
|
if (buffer[i] === 0x77) {
|
||||||
|
packetLen = i + 1;
|
||||||
|
// Optimization: Don't just take the first 77 if it's too short to be valid?
|
||||||
|
// But we just want to try parsing it.
|
||||||
|
packetFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packetFound) {
|
||||||
|
const packet = new Uint8Array(buffer.slice(0, packetLen));
|
||||||
|
const { payload, error } = JBDProtocol.parseResponse(packet);
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
// Success! Remove this packet from buffer and return payload
|
||||||
|
// (NOTE: This returns the FIRST valid packet found.
|
||||||
|
// If multiple are queued, subsequent calls will handle them?
|
||||||
|
// No, executeCommandUnsafe expects ONE return.
|
||||||
|
// But we might have read garbage before.)
|
||||||
|
return payload;
|
||||||
|
} else {
|
||||||
|
// Checksum failed or structure invalid
|
||||||
|
this.log('error', `Packet Err: ${error}`);
|
||||||
|
// If scanning found a 77 but checksum failed, it might be a coincidence data byte 0x77.
|
||||||
|
// We should probably consume the buffer up to that point?
|
||||||
|
// Or just consume the start byte (0xDD) and retry?
|
||||||
|
// Safer to consume just the start byte to try finding another sync.
|
||||||
|
buffer.shift();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Packet incomplete, wait for more data
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.reader = null;
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggleMosfet(charge: boolean, discharge: boolean): Promise<void> {
|
||||||
|
const cBit = charge ? 0 : 1;
|
||||||
|
const dBit = discharge ? 0 : 1;
|
||||||
|
const controlByte = cBit | (dBit << 1);
|
||||||
|
await this.sendCommand(0xE1, new Uint8Array([0x00, controlByte]), WRITE_CMD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serialService = new SerialService();
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
131
types.ts
Normal file
131
types.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
|
||||||
|
export interface BMSBasicInfo {
|
||||||
|
packVoltage: number; // Volts
|
||||||
|
current: number; // Amps
|
||||||
|
remainingCapacity: number; // Ah
|
||||||
|
fullCapacity: number; // Ah
|
||||||
|
cycleCount: number;
|
||||||
|
productionDate: string;
|
||||||
|
balanceStatus: number; // Bitfield
|
||||||
|
protectionStatus: ProtectionStatus;
|
||||||
|
version: number;
|
||||||
|
rsoc: number; // %
|
||||||
|
mosfetStatus: {
|
||||||
|
charge: boolean;
|
||||||
|
discharge: boolean;
|
||||||
|
};
|
||||||
|
ntcCount: number;
|
||||||
|
ntcTemps: number[]; // Celsius
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BMSCellInfo {
|
||||||
|
voltages: number[]; // Volts
|
||||||
|
}
|
||||||
|
|
||||||
|
// C# JbdDevice.cs의 레지스터 목록과 1:1 매칭되도록 확장
|
||||||
|
export interface BMSConfig {
|
||||||
|
// --- Basic Protection ---
|
||||||
|
cellOvp: number; // 0x24 (mV)
|
||||||
|
cellOvpRel: number; // 0x25 (mV)
|
||||||
|
cellUvp: number; // 0x26 (mV)
|
||||||
|
cellUvpRel: number; // 0x27 (mV)
|
||||||
|
packOvp: number; // 0x20 (10mV)
|
||||||
|
packOvpRel: number; // 0x21 (10mV)
|
||||||
|
packUvp: number; // 0x22 (10mV)
|
||||||
|
packUvpRel: number; // 0x23 (10mV)
|
||||||
|
|
||||||
|
// Temperatures (Basic)
|
||||||
|
chgOt: number; // 0x18
|
||||||
|
chgOtRel: number; // 0x19
|
||||||
|
chgUt: number; // 0x1A
|
||||||
|
chgUtRel: number; // 0x1B
|
||||||
|
dsgOt: number; // 0x1C
|
||||||
|
dsgOtRel: number; // 0x1D
|
||||||
|
dsgUt: number; // 0x1E (C# mapping check needed, using std logic)
|
||||||
|
dsgUtRel: number; // 0x1F
|
||||||
|
|
||||||
|
// Over Current
|
||||||
|
chgOc: number; // 0x?? Depends on mapping, typically extended
|
||||||
|
dsgOc: number;
|
||||||
|
|
||||||
|
// --- Delays (2 bytes split) ---
|
||||||
|
// Format: [Delay1 (s/ms), Delay2 (s/ms)]
|
||||||
|
cellVDelays: { cuvp: number; covp: number }; // 0x3D
|
||||||
|
packVDelays: { puvp: number; povp: number }; // 0x3C
|
||||||
|
chgTDelays: { chgut: number; chgot: number }; // 0x3A
|
||||||
|
dsgTDelays: { dsgut: number; dsgot: number }; // 0x3B
|
||||||
|
chgOcDelays: { delay: number; release: number }; // 0x3E
|
||||||
|
dsgOcDelays: { delay: number; release: number }; // 0x3F
|
||||||
|
|
||||||
|
// --- High Protection ---
|
||||||
|
covpHigh: number; // 0x36
|
||||||
|
cuvpHigh: number; // 0x37
|
||||||
|
|
||||||
|
// --- Function Config (Bitfield 0x2D) ---
|
||||||
|
funcConfig: number; // Raw uint16 value
|
||||||
|
|
||||||
|
// --- NTC Config (Bitfield 0x2E) ---
|
||||||
|
ntcConfig: number; // Raw uint16 value
|
||||||
|
|
||||||
|
// --- Balance ---
|
||||||
|
balStart: number; // 0x2A (mV)
|
||||||
|
balWindow: number; // 0x2B (mV)
|
||||||
|
|
||||||
|
// --- Capacity & Misc ---
|
||||||
|
designCapacity: number; // 0x10 (10mAh)
|
||||||
|
cycleCapacity: number; // 0x11 (10mAh)
|
||||||
|
dsgRate: number; // 0x14 (%)
|
||||||
|
cap100: number; // 0x12
|
||||||
|
cap80: number; // 0x32
|
||||||
|
cap60: number; // 0x33
|
||||||
|
cap40: number; // 0x34
|
||||||
|
cap20: number; // 0x35
|
||||||
|
cap0: number; // 0x13
|
||||||
|
fetCtrl: number; // 0x30 (s)
|
||||||
|
ledTimer: number; // 0x31 (s)
|
||||||
|
|
||||||
|
// --- Hardware / Misc ---
|
||||||
|
shuntRes: number; // 0x2C (mOhm)
|
||||||
|
cellCnt: number; // 0x2F
|
||||||
|
cycleCnt: number; // 0x17
|
||||||
|
serialNum: number; // 0x16
|
||||||
|
|
||||||
|
// --- Strings ---
|
||||||
|
mfgName: string; // 0xA0
|
||||||
|
deviceName: string; // 0xA1
|
||||||
|
barcode: string; // 0xA2
|
||||||
|
|
||||||
|
// --- Date ---
|
||||||
|
mfgDate: number; // 0x15 (Raw uint16 encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectionStatus {
|
||||||
|
covp: boolean; // Cell Over Voltage Protection
|
||||||
|
cuvp: boolean; // Cell Under Voltage Protection
|
||||||
|
povp: boolean; // Pack Over Voltage Protection
|
||||||
|
puvp: boolean; // Pack Under Voltage Protection
|
||||||
|
chgot: boolean; // Charge Over Temp
|
||||||
|
chgut: boolean; // Charge Under Temp
|
||||||
|
dsgot: boolean; // Discharge Over Temp
|
||||||
|
dsgut: boolean; // Discharge Under Temp
|
||||||
|
chgoc: boolean; // Charge Over Current
|
||||||
|
dsgoc: boolean; // Discharge Over Current
|
||||||
|
sc: boolean; // Short Circuit
|
||||||
|
afe: boolean; // AFE Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ConnectionState {
|
||||||
|
DISCONNECTED = 'DISCONNECTED',
|
||||||
|
CONNECTING = 'CONNECTING',
|
||||||
|
CONNECTED = 'CONNECTED',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerialOptions {
|
||||||
|
baudRate: number;
|
||||||
|
dataBits: number;
|
||||||
|
stopBits: number;
|
||||||
|
parity: ParityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParityType = 'none' | 'even' | 'odd';
|
||||||
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