"Initial_commit"

This commit is contained in:
2025-12-19 00:55:55 +09:00
commit 0bfc6ffb19
15 changed files with 2103 additions and 0 deletions

430
components/Dashboard.tsx Normal file
View 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
View 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
View 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;