336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
|
|
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();
|