From 0bfc6ffb196d7ffb8d8b2241378a59a00f8314e7 Mon Sep 17 00:00:00 2001 From: LGram16 Date: Fri, 19 Dec 2025 00:55:55 +0900 Subject: [PATCH] "Initial_commit" --- .gitignore | 24 +++ App.tsx | 251 ++++++++++++++++++++++ README.md | 20 ++ components/Dashboard.tsx | 430 ++++++++++++++++++++++++++++++++++++++ components/Settings.tsx | 357 +++++++++++++++++++++++++++++++ components/Terminal.tsx | 151 +++++++++++++ index.html | 57 +++++ index.tsx | 15 ++ metadata.json | 7 + package.json | 23 ++ services/jbdProtocol.ts | 250 ++++++++++++++++++++++ services/serialService.ts | 335 +++++++++++++++++++++++++++++ tsconfig.json | 29 +++ types.ts | 131 ++++++++++++ vite.config.ts | 23 ++ 15 files changed, 2103 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 README.md create mode 100644 components/Dashboard.tsx create mode 100644 components/Settings.tsx create mode 100644 components/Terminal.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/jbdProtocol.ts create mode 100644 services/serialService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..c32ab55 --- /dev/null +++ b/App.tsx @@ -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.DISCONNECTED); + const [basicInfo, setBasicInfo] = useState(null); + const [cellInfo, setCellInfo] = useState(null); + const [hwVersion, setHwVersion] = useState(null); + const [activeTab, setActiveTab] = useState<'dashboard' | 'settings'>('dashboard'); + const [errorMsg, setErrorMsg] = useState(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 ; + case 'settings': + return ; + default: + return null; + } + }; + + return ( +
+ + {/* Sidebar */} +
+
+
+ J +
+ JBD Tool +
+ + + +
+
+ {connectionState === ConnectionState.CONNECTED ? ( + + ) : ( + + )} +
+
+
+ + {/* Main Content Area */} +
+ {/* Header */} +
+

+ {activeTab === 'dashboard' ? '개요 (Overview)' : '설정 (Configuration)'} +

+
+ {connectionState === ConnectionState.CONNECTED && ( +
+
+ CONNECTED +
+ )} + {connectionState === ConnectionState.DISCONNECTED && ( +
+
+ OFFLINE +
+ )} +
+
+ + {/* Content Body with Right Sidebar */} +
+ {/* Main View */} +
+ {errorMsg && ( +
+ + {errorMsg} +
+ )} + + {renderContent()} + + {/* Empty State Overlay */} + {connectionState !== ConnectionState.CONNECTED && ( +
+
+ +
+

장치가 연결되지 않았습니다

+

+ JBD BMS를 UART-to-USB 어댑터로 연결하여 실시간 상태를 확인하고 설정을 변경하세요. +

+ +
+ )} +
+ + {/* Right Terminal Panel */} + +
+
+
+ ); +}; + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2e973a --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# 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` diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx new file mode 100644 index 0000000..4ffaef4 --- /dev/null +++ b/components/Dashboard.tsx @@ -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 }) => ( +
+
+ {React.isValidElement(icon) && React.cloneElement(icon as React.ReactElement, { size: 48 })} +
+
+ {label} +
{icon}
+
+
+
+ {value} + {unit && {unit}} +
+ {subValue &&
{subValue}
} +
+
+); + +const MosfetSwitch: React.FC<{ label: string; active: boolean; onClick: () => void; disabled: boolean }> = ({ label, active, onClick, disabled }) => ( + +); + +const ProtectionBadge: React.FC<{ label: string; active: boolean }> = ({ label, active }) => ( +
+ {label} +
+); + +const Dashboard: React.FC = ({ basicInfo, cellInfo, hwVersion, onToggleMosfet }) => { + const [isRecording, setIsRecording] = useState(false); + const logDataRef = useRef([]); + const [logCount, setLogCount] = useState(0); + const [voltageHistory, setVoltageHistory] = useState([]); + + // 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 ( +
+ + {/* Top Metrics Row */} +
+ } + color="text-yellow-400" + /> + } + 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} + /> + } + // 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} + /> + } + color="text-blue-400" + /> + } + color={deltaVoltage > 0.03 ? "text-red-400" : "text-emerald-400"} + /> + } + color="text-purple-400" + /> +
+ +
+ + {/* Left Column: Charts & Status */} +
+ + {/* Combined Cell Voltage Monitor */} +
+ + {/* Header */} +
+

+ + Cell Monitor (Instant & Trend) +

+
+ Max: {maxVoltage.toFixed(3)}V +
+ Min: {minVoltage.toFixed(3)}V +
+
+ + {/* 1. Bar Chart (Instant) */} +
+
Instant Voltage
+ + + + 0 ? minVoltage - 0.05 : 2.5, maxVoltage > 0 ? maxVoltage + 0.05 : 4.5]} stroke="#4b5563" tick={{fontSize: 10}} /> + [value.toFixed(3) + ' V', 'Voltage']} + /> + + + {cellChartData.map((entry, index) => ( + + ))} + + + +
+ +
+ + {/* 2. Line Chart (Trend) */} +
+
+ Voltage History (~1 min) +
+ + + + {/* Hide XAxis ticks as requested */} + + + [value.toFixed(3) + ' V', name]} + /> + {/* Dynamically create thin lines for each cell */} + {Array.from({ length: cellCount }).map((_, i) => ( + + ))} + + +
+
+ + {/* Protections & Temps */} +
+ {/* Protections */} +
+

+ + Protection Status +

+
+ {protectionList.map((p) => ( + // @ts-ignore + + ))} +
+
+ + {/* Temperatures */} +
+

+ + Temperatures +

+
+ {basicInfo && basicInfo.ntcTemps.length > 0 ? basicInfo.ntcTemps.map((t, i) => ( +
+ NTC {i + 1} + {t.toFixed(1)}°C +
+ )) : ( + // Placeholder +
+ NTC 1 + --.-°C +
+ )} +
+
+
+
+ + {/* Right Column: Controls & Capacity */} +
+ + {/* MOSFET Control */} +
+

+ + MOSFET Control +

+
+ onToggleMosfet('charge', basicInfo?.mosfetStatus.charge ?? false)} + /> + onToggleMosfet('discharge', basicInfo?.mosfetStatus.discharge ?? false)} + /> +
+
+ + {/* Capacity Info */} +
+

+ + Capacity Info +

+
+
+ Design Capacity + {basicInfo ? basicInfo.fullCapacity.toFixed(2) : '-.--'} Ah +
+
+ Remaining Cap + {basicInfo ? basicInfo.remainingCapacity.toFixed(2) : '-.--'} Ah +
+
+ Mfg Date + {basicInfo ? basicInfo.productionDate : 'YYYY-MM-DD'} +
+
+ Device Ver + {hwVersion || '--'} +
+
+
+ + {/* Logger */} +
+
+

Data Logger

+ {isRecording && ● REC ({logCount})} +
+ +
+ +
+
+
+ ); +}; + +export default Dashboard; diff --git a/components/Settings.tsx b/components/Settings.tsx new file mode 100644 index 0000000..7902630 --- /dev/null +++ b/components/Settings.tsx @@ -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>({}); + const [busyReg, setBusyReg] = useState(null); // 현재 작업 중인 레지스터 (로딩 표시용) + const [globalLoading, setGlobalLoading] = useState(false); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(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 = {}; + + 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}) => ( +
+
+

+ {title} +

+
+
{children}
+
+ ); + + // 개별 설정 행 컴포넌트 + 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(''); + 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) => { + 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 ( +
+
+
{label}
+
ADDR: 0x{reg.toString(16).toUpperCase().padStart(2, '0')}
+
+ +
+
+ + {unit && {type !== 'date' ? unit : ''}} +
+ + + + +
+
+ ); + }; + + return ( +
+ {/* 상단 툴바 */} +
+
+

EEPROM 설정

+

BMS 내부 파라미터를 개별적으로 읽거나 수정할 수 있습니다.

+
+
+ +
+
+ + {/* 메시지 영역 */} +
+ {error && ( +
+ + {error} +
+ )} + {successMsg && ( +
+ + {successMsg} +
+ )} +
+ + {/* 설정 그리드 */} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; + +export default Settings; \ No newline at end of file diff --git a/components/Terminal.tsx b/components/Terminal.tsx new file mode 100644 index 0000000..cccd743 --- /dev/null +++ b/components/Terminal.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [autoScroll, setAutoScroll] = useState(true); + const logsContainerRef = useRef(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 ( +
+
+
+ + 터미널 / Logs +
+
+ + +
+
+ +
+ {/* Input Area (Top) or Bottom? Keeping input at bottom is standard, logs flow down from top */} + +
+ {logs.length === 0 &&
대기 중... (Logs)
} + + {logs.map(log => ( +
+ [{log.timestamp}] +
+ {log.type === 'tx' && TX} + {log.type === 'rx' && RX} + {log.type === 'error' && ERR} + {log.type === 'info' && INF} + + {log.data} + +
+
+ ))} +
+ +
+
+ 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" + /> + +
+
+
+
+ ); +}; + +export default Terminal; diff --git a/index.html b/index.html new file mode 100644 index 0000000..ed9ee45 --- /dev/null +++ b/index.html @@ -0,0 +1,57 @@ + + + + + + JBD BMS Web Tool + + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..39e1012 --- /dev/null +++ b/index.tsx @@ -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( + + + +); diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..4a3f9b5 --- /dev/null +++ b/metadata.json @@ -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" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c98c8cc --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/jbdProtocol.ts b/services/jbdProtocol.ts new file mode 100644 index 0000000..b90987e --- /dev/null +++ b/services/jbdProtocol.ts @@ -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= 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), + }; + } +} diff --git a/services/serialService.ts b/services/serialService.ts new file mode 100644 index 0000000..2f1433e --- /dev/null +++ b/services/serialService.ts @@ -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 = 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 { + 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 { + 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 { + await this.writeRegisterRaw(0x00, new Uint8Array([0x56, 0x78])); + } + + public async enterFactoryModeWrite(): Promise { + await this.writeRegisterRaw(0x01, new Uint8Array([0x28, 0x28])); + } + + public async exitFactoryMode(): Promise { + await this.writeRegisterRaw(0x01, new Uint8Array([0x00, 0x00])); + } + + public async sendCommand(command: number, data: Uint8Array = new Uint8Array(0), mode: number = READ_CMD): Promise { + const result = this.transportLock.then(() => this.executeCommandUnsafe(command, data, mode)); + this.transportLock = result.then(() => {}).catch(() => {}); + return result; + } + + public async sendRaw(data: Uint8Array): Promise { + 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 { + 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 { + 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 { + return await this.sendCommand(reg, new Uint8Array(0), READ_CMD); + } + + public async writeRegister(reg: number, value: number): Promise { + const data = new Uint8Array([(value >> 8) & 0xFF, value & 0xFF]); + await this.sendCommand(reg, data, WRITE_CMD); + } + + private async writeRegisterRaw(reg: number, data: Uint8Array): Promise { + await this.sendCommand(reg, data, WRITE_CMD); + } + + public async writeRegisterBytes(reg: number, data: Uint8Array): Promise { + await this.sendCommand(reg, data, WRITE_CMD); + } + + private async executeCommandUnsafe(command: number, data: Uint8Array, mode: number): Promise { + 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 { + 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 { + 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(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..bc805db --- /dev/null +++ b/types.ts @@ -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'; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});