"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

250
services/jbdProtocol.ts Normal file
View 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
View 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();