597 lines
21 KiB
TypeScript
597 lines
21 KiB
TypeScript
import {
|
|
BLE_SERVICE_UUID,
|
|
BLE_CHAR_FLASHING_UUID,
|
|
BLE_CHAR_SERIAL_UUID,
|
|
CMD_INIT, CMD_DATA, CMD_END, CMD_ACK, CMD_ERR,
|
|
CHUNK_SIZE, BLE_TIMEOUT_MS, END_TIMEOUT_MS, END_ACK_INDEX, MAX_RETRIES,
|
|
type DeployProgress, type BLEACKResponse
|
|
} from '$types/deployer';
|
|
|
|
let _ackNotificationCount = 0;
|
|
function logAck(tag: string, msg: string) {
|
|
console.log(`[BLE-ACK#${++_ackNotificationCount}] ${tag}: ${msg}`);
|
|
}
|
|
|
|
export class BLEHardwareDeployer {
|
|
private server: BluetoothRemoteGATTServer | null = null;
|
|
private service: BluetoothRemoteGATTService | null = null;
|
|
private flashingChar: BluetoothRemoteGATTCharacteristic | null = null;
|
|
private serialChar: BluetoothRemoteGATTCharacteristic | null = null;
|
|
private device: BluetoothDevice | null = null;
|
|
|
|
private onSerialData: ((text: string) => void) | null = null;
|
|
|
|
/** Map of expected ACK index → resolver pair. Supports concurrent waits. */
|
|
private ackResolvers = new Map<number, {
|
|
resolve: (value: BLEACKResponse) => void;
|
|
reject: (reason: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}>();
|
|
|
|
/** ACKs that arrived before waitForAck was called, keyed by index. */
|
|
private pendingAcks = new Map<number, BLEACKResponse>();
|
|
|
|
private _onDisconnected: (() => void) | null = null;
|
|
|
|
/** Bound handler refs so we can removeEventListener on cleanup/re-pair. */
|
|
private _onDeviceDisconnect: (() => void) | null = null;
|
|
private _onFlashingNotification: ((event: Event) => void) | null = null;
|
|
private _onSerialNotification: ((event: Event) => void) | null = null;
|
|
|
|
checkSupport(): boolean {
|
|
return typeof navigator !== 'undefined' && 'bluetooth' in navigator;
|
|
}
|
|
|
|
get isConnected(): boolean {
|
|
return this.server?.connected ?? false;
|
|
}
|
|
|
|
private withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<T>((_, reject) => {
|
|
setTimeout(() => reject(new Error(`Timeout: ${label} (${ms}ms)`)), ms);
|
|
})
|
|
]);
|
|
}
|
|
|
|
async pair(): Promise<void> {
|
|
if (!this.checkSupport()) {
|
|
throw new Error('Web Bluetooth tidak didukung di browser ini. Gunakan Chrome Android.');
|
|
}
|
|
|
|
// Clean up any previous session's listeners before re-pairing
|
|
this.cleanup();
|
|
|
|
console.log('[BLE] step 1: requestDevice...');
|
|
this.device = await navigator.bluetooth.requestDevice({
|
|
filters: [{ namePrefix: 'Velxio' }],
|
|
optionalServices: [BLE_SERVICE_UUID]
|
|
});
|
|
console.log('[BLE] step 1: device selected:', this.device.name);
|
|
|
|
this._onDeviceDisconnect = () => {
|
|
console.log('[BLE] gattserverdisconnected event');
|
|
this._onDisconnected?.();
|
|
this.cleanup();
|
|
};
|
|
this.device.addEventListener('gattserverdisconnected', this._onDeviceDisconnect);
|
|
|
|
console.log('[BLE] step 2: gatt.connect...');
|
|
this.server = await this.withTimeout(
|
|
this.device.gatt!.connect(),
|
|
10000,
|
|
'gatt.connect'
|
|
);
|
|
console.log('[BLE] step 2: connected, mtu=' + this.server.mtu);
|
|
|
|
console.log('[BLE] step 3: getPrimaryService...');
|
|
this.service = await this.withTimeout(
|
|
this.server.getPrimaryService(BLE_SERVICE_UUID),
|
|
5000,
|
|
'getPrimaryService'
|
|
);
|
|
console.log('[BLE] step 3: service found');
|
|
|
|
console.log('[BLE] step 4: getCharacteristic flashing...');
|
|
this.flashingChar = await this.withTimeout(
|
|
this.service.getCharacteristic(BLE_CHAR_FLASHING_UUID),
|
|
5000,
|
|
'getCharacteristic(flashing)'
|
|
);
|
|
console.log('[BLE] step 4: flashing char found');
|
|
|
|
console.log('[BLE] step 5: getCharacteristic serial...');
|
|
this.serialChar = await this.withTimeout(
|
|
this.service.getCharacteristic(BLE_CHAR_SERIAL_UUID),
|
|
5000,
|
|
'getCharacteristic(serial)'
|
|
);
|
|
console.log('[BLE] step 5: serial char found');
|
|
|
|
console.log('[BLE] step 6: requestMTU(255)...');
|
|
try {
|
|
await this.withTimeout(
|
|
this.server.requestMTU(255),
|
|
3000,
|
|
'requestMTU'
|
|
);
|
|
console.log('[BLE] step 6: MTU negotiated, now ' + this.server.mtu);
|
|
} catch (e) {
|
|
console.log('[BLE] step 6: MTU fallback, mtu=' + this.server.mtu, e);
|
|
}
|
|
|
|
console.log('[BLE] step 7: startNotifications...');
|
|
await this.withTimeout(
|
|
this.flashingChar.startNotifications(),
|
|
5000,
|
|
'startNotifications'
|
|
);
|
|
console.log('[BLE] step 7: notifications subscribed');
|
|
|
|
this._onFlashingNotification = (event: Event) => {
|
|
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
|
const dv = target.value!;
|
|
const value = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
|
this.handleFlashingNotification(value);
|
|
};
|
|
this.flashingChar.addEventListener('characteristicvaluechanged', this._onFlashingNotification);
|
|
console.log('[BLE] pair() completed successfully');
|
|
}
|
|
|
|
private handleFlashingNotification(value: Uint8Array) {
|
|
const cmd = value[0];
|
|
const index = value[1] | (value[2] << 8);
|
|
const cmdName = cmd === CMD_ACK ? 'ACK' : cmd === CMD_ERR ? 'ERR' : 'UNKNOWN';
|
|
logAck(cmdName, `index=${index} len=${value.length}`);
|
|
|
|
if (cmd === CMD_ACK) {
|
|
const ack: BLEACKResponse = { command: cmd, index, status: 'OK' };
|
|
|
|
// Check if someone is waiting for this exact index
|
|
const pending = this.ackResolvers.get(index);
|
|
if (pending) {
|
|
logAck(cmdName, `Found resolver for index=${index} — resolving`);
|
|
this.ackResolvers.delete(index);
|
|
clearTimeout(pending.timer);
|
|
pending.resolve(ack);
|
|
} else if (this.ackResolvers.has(0xFFFF) && index !== 0xFFFF) {
|
|
// No one waiting for this specific index right now,
|
|
// but someone IS waiting for END (0xFFFF) — don't
|
|
// let a stale chunk ACK resolve it.
|
|
logAck(cmdName, `index=${index} no resolver, END waitForAck(0xFFFF) active — dropping stale ACK`);
|
|
} else {
|
|
// Cache for a future waitForAck call
|
|
logAck(cmdName, `index=${index} no resolver — caching as pendingAck`);
|
|
this.pendingAcks.set(index, ack);
|
|
}
|
|
} else if (cmd === CMD_ERR) {
|
|
const msgLen = value[3];
|
|
const msg = msgLen > 0
|
|
? new TextDecoder().decode(value.slice(4, 4 + msgLen))
|
|
: 'Unknown error';
|
|
logAck(cmdName, `index=${index} ERR: ${msg}`);
|
|
|
|
const pending = this.ackResolvers.get(index);
|
|
if (pending) {
|
|
logAck(cmdName, `Found rejecter for index=${index} — rejecting`);
|
|
this.ackResolvers.delete(index);
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new Error(`ERR: ${msg}`));
|
|
} else {
|
|
logAck(cmdName, `index=${index} no rejecter — ERR lost: ${msg}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async deployHex(hexContent: string, onProgress: (p: DeployProgress) => void): Promise<void> {
|
|
if (!this.flashingChar) throw new Error('Belum terhubung ke perangkat');
|
|
|
|
const binaryData = this.hexToBinary(hexContent);
|
|
const totalChunks = Math.ceil(binaryData.length / CHUNK_SIZE);
|
|
const totalCRC = this.crc32(binaryData);
|
|
|
|
console.log(`[BLE] deployHex: ${binaryData.length} bytes, ${totalChunks} chunks, CRC=0x${totalCRC.toString(16)}`);
|
|
|
|
onProgress({ state: 'transferring', message: 'Mengirim INIT...', totalChunks, completedChunks: 0 });
|
|
console.log('[BLE] D: sending INIT...');
|
|
await this.sendCommand(CMD_INIT, 0, this.uint32ToBytes(totalCRC));
|
|
console.log('[BLE] D: INIT done');
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const start = i * CHUNK_SIZE;
|
|
const end = Math.min(start + CHUNK_SIZE, binaryData.length);
|
|
const chunk = binaryData.slice(start, end);
|
|
const chunkCRC = this.crc32(chunk);
|
|
|
|
await this.sendCommandWithRetry(CMD_DATA, i, chunk, chunkCRC);
|
|
|
|
onProgress({
|
|
state: 'transferring',
|
|
message: `Mengirim chunk ${i + 1}/${totalChunks}`,
|
|
totalChunks,
|
|
completedChunks: i + 1
|
|
});
|
|
}
|
|
console.log('[BLE] D: all DATA chunks done');
|
|
|
|
onProgress({ state: 'flashing', message: 'Verifikasi & flashing...', totalChunks, completedChunks: totalChunks });
|
|
console.log('[BLE] D: END starting (30s timeout)...');
|
|
const startTime = Date.now();
|
|
try {
|
|
await this.sendCommand(CMD_END, END_ACK_INDEX, this.uint32ToBytes(totalCRC), END_TIMEOUT_MS);
|
|
const elapsed = Date.now() - startTime;
|
|
console.log(`[BLE] D: END ACK received after ${elapsed}ms, flash success!`);
|
|
} catch (err) {
|
|
const elapsed = Date.now() - startTime;
|
|
console.log(`[BLE] D: END FAILED after ${elapsed}ms:`, err);
|
|
throw err;
|
|
}
|
|
|
|
onProgress({ state: 'serial_bridge', message: 'Flash berhasil! Membuka Serial Monitor...', totalChunks, completedChunks: totalChunks });
|
|
}
|
|
|
|
private async sendCommand(cmd: number, index: number, data: Uint8Array, timeoutMs?: number): Promise<void> {
|
|
const cmdName = cmd === CMD_INIT ? 'INIT' : cmd === CMD_DATA ? 'DATA' : cmd === CMD_END ? 'END' : `0x${cmd.toString(16)}`;
|
|
const payload = new Uint8Array(4 + data.length + 4);
|
|
payload[0] = cmd;
|
|
payload[1] = index & 0xFF;
|
|
payload[2] = (index >> 8) & 0xFF;
|
|
payload[3] = data.length;
|
|
payload.set(data, 4);
|
|
|
|
const crc = this.crc32(data);
|
|
payload.set([
|
|
crc & 0xFF, (crc >> 8) & 0xFF, (crc >> 16) & 0xFF, (crc >> 24) & 0xFF
|
|
], 4 + data.length);
|
|
|
|
const ackTimeout = timeoutMs ?? BLE_TIMEOUT_MS;
|
|
console.log(`[BLE-CMD] ${cmdName} idx=${index}: waitForAck timeout=${ackTimeout}ms start`);
|
|
const ackPromise = this.waitForAck(index, ackTimeout);
|
|
try {
|
|
console.log(`[BLE-CMD] ${cmdName} idx=${index}: writing payload (${payload.length} bytes)...`);
|
|
await this.withTimeout(
|
|
this.flashingChar!.writeValueWithResponse(payload),
|
|
3000,
|
|
'writeValueWithResponse'
|
|
);
|
|
console.log(`[BLE-CMD] ${cmdName} idx=${index}: write done, now waiting for ACK...`);
|
|
} catch (e) {
|
|
console.log(`[BLE-CMD] ${cmdName} idx=${index}: write failed, still waiting for ACK`, e);
|
|
}
|
|
|
|
/* END (flash ~8s): Android BLE stack (Qualcomm) drops
|
|
* characteristicvaluechanged events after ~8s idle, so ACK
|
|
* notification is unreliable. Use readValue() to poll the
|
|
* firmware's state machine instead — GATT Read is client-
|
|
* initiated and NOT affected by the notify idle-drop bug.
|
|
*
|
|
* CCCD refresh is fire-and-forget as a belt-and-suspenders:
|
|
* it didn't solve the issue on Qualcomm, but may help on
|
|
* other devices. */
|
|
if (cmd === CMD_END) {
|
|
/* Fire-and-forget CCCD refresh (don't await — non-blocking). */
|
|
this.flashingChar!.stopNotifications().catch(() => {});
|
|
this.flashingChar!.startNotifications().then(
|
|
() => console.log('[BLE-CMD] END: CCCD re-subscribed (fire-and-forget)'),
|
|
(e) => console.log('[BLE-CMD] END: CCCD refresh failed (non-critical)', e)
|
|
);
|
|
|
|
/* Read-based state poll — the RELIABLE path.
|
|
* State values from state_machine.h:
|
|
* 0=IDLE, 1=RECEIVING, 2=VERIFYING, 3=FLASHING (keep polling),
|
|
* 4=SERIAL_BRIDGE (success), 5=ERROR_TARGET, 6=ERROR_CHECKSUM */
|
|
const STATE_SERIAL_BRIDGE = 4;
|
|
const STATE_ERROR_TARGET = 5;
|
|
const STATE_ERROR_CHECKSUM = 6;
|
|
|
|
const readPoll = (async () => {
|
|
const deadline = Date.now() + ackTimeout;
|
|
let firstPoll = true;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const dv = await this.withTimeout(
|
|
this.flashingChar!.readValue(),
|
|
3000,
|
|
'readValue(state)'
|
|
);
|
|
const state = dv.getUint8(0);
|
|
console.log(`[BLE-CMD] END: readValue state=${state}`);
|
|
|
|
if (state === STATE_SERIAL_BRIDGE) {
|
|
console.log('[BLE-CMD] END: state=SERIAL_BRIDGE — flash success via read poll');
|
|
return 'ok' as const;
|
|
}
|
|
if (state === STATE_ERROR_TARGET || state === STATE_ERROR_CHECKSUM) {
|
|
console.log(`[BLE-CMD] END: state=ERROR(${state}) — flash failed via read poll`);
|
|
throw new Error('Flash gagal di sisi firmware (state=' + state + ')');
|
|
}
|
|
/* state=FLASHING or VERIFYING — keep polling */
|
|
} catch (e) {
|
|
/* readValue failed (GATT error or timeout) — keep
|
|
* retrying unless the connection itself is dead. */
|
|
if (!this.isConnected) {
|
|
throw new Error('Koneksi BLE terputus saat menunggu flash');
|
|
}
|
|
console.log('[BLE-CMD] END: readValue retry...', e);
|
|
}
|
|
/* First poll at 200ms (fast check if flash already done),
|
|
* then every 500ms. ~16 polls during 8s flash. */
|
|
await new Promise<void>(r => setTimeout(r, firstPoll ? 200 : 500));
|
|
firstPoll = false;
|
|
}
|
|
console.log('[BLE-CMD] END: readPoll deadline passed (30s)');
|
|
return 'timeout' as const;
|
|
})();
|
|
|
|
/* Race: ACK notify (fast path — works on some devices) vs
|
|
* readValue poll (reliable path — works everywhere). */
|
|
const ackWithLabel = ackPromise.then(() => 'ack' as const);
|
|
const result = await Promise.race([ackWithLabel, readPoll]);
|
|
console.log(`[BLE-CMD] END idx=${index}: resolved via ${result}`);
|
|
|
|
/* Clean up the ackPromise resolver if readPoll won —
|
|
* prevents a dangling 30s timer. */
|
|
if (result !== 'ack') {
|
|
const entry = this.ackResolvers.get(index);
|
|
if (entry) {
|
|
clearTimeout(entry.timer);
|
|
this.ackResolvers.delete(index);
|
|
console.log(`[BLE-CMD] END: cleaned up ackPromise resolver (won via ${result})`);
|
|
}
|
|
}
|
|
|
|
if (result === 'timeout') {
|
|
throw new Error(`Timeout menunggu ACK untuk chunk ${index}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* INIT/DATA: Same Android notify idle-drop bug affects these too.
|
|
* Race ackPromise against readValue() state poll as fallback.
|
|
* INIT success: firmware state >= RECEIVING(1) (not IDLE/ERROR).
|
|
* DATA success: firmware state == RECEIVING(1). */
|
|
if (cmd === CMD_INIT || cmd === CMD_DATA) {
|
|
const readPoll = (async () => {
|
|
const deadline = Date.now() + ackTimeout;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const dv = await this.withTimeout(
|
|
this.flashingChar!.readValue(),
|
|
3000,
|
|
'readValue(state)'
|
|
);
|
|
const state = dv.getUint8(0);
|
|
console.log(`[BLE-CMD] ${cmdName}: readValue state=${state}`);
|
|
|
|
/* INIT: any non-IDLE(0), non-ERROR(5/6) state means command was processed.
|
|
* DATA: state must stay RECEIVING(1) — error if >=5. */
|
|
if (cmd === CMD_INIT) {
|
|
if (state >= 1 && state <= 4) {
|
|
console.log(`[BLE-CMD] ${cmdName}: state=${state} — INIT confirmed via read poll`);
|
|
return 'ok' as const;
|
|
}
|
|
} else { /* DATA */
|
|
if (state === 1) {
|
|
console.log(`[BLE-CMD] ${cmdName}: state=1 (RECEIVING) — DATA confirmed via read poll`);
|
|
return 'ok' as const;
|
|
}
|
|
}
|
|
if (state >= 5) {
|
|
throw new Error(`Firmware error (state=${state})`);
|
|
}
|
|
} catch (e) {
|
|
if (!this.isConnected) {
|
|
throw new Error('Koneksi BLE terputus');
|
|
}
|
|
console.log(`[BLE-CMD] ${cmdName}: readValue retry...`, e);
|
|
}
|
|
await new Promise<void>(r => setTimeout(r, 200));
|
|
}
|
|
console.log(`[BLE-CMD] ${cmdName}: readPoll deadline passed (${ackTimeout}ms)`);
|
|
return 'timeout' as const;
|
|
})();
|
|
|
|
const ackWithLabel = ackPromise.then(() => 'ack' as const);
|
|
const result = await Promise.race([ackWithLabel, readPoll]);
|
|
console.log(`[BLE-CMD] ${cmdName} idx=${index}: resolved via ${result}`);
|
|
|
|
if (result !== 'ack') {
|
|
const entry = this.ackResolvers.get(index);
|
|
if (entry) {
|
|
clearTimeout(entry.timer);
|
|
this.ackResolvers.delete(index);
|
|
}
|
|
}
|
|
if (result === 'timeout') {
|
|
throw new Error(`Timeout menunggu ACK untuk chunk ${index}`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async sendCommandWithRetry(cmd: number, index: number, data: Uint8Array, _chunkCRC: number): Promise<void> {
|
|
/* Route through sendCommand() which has readValue() state poll
|
|
* fallback for DATA (same as INIT/END). The _chunkCRC is kept
|
|
* for API compatibility — sendCommand computes CRC internally. */
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
try {
|
|
await this.sendCommand(cmd, index, data);
|
|
return;
|
|
} catch (err) {
|
|
if (attempt === MAX_RETRIES - 1) throw err;
|
|
console.log(`[BLE-CMD] DATA idx=${index}: retry ${attempt + 1}/${MAX_RETRIES}`, err);
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
}
|
|
}
|
|
|
|
private waitForAck(expectedIndex: number, timeoutMs: number = BLE_TIMEOUT_MS): Promise<BLEACKResponse> {
|
|
return new Promise((resolve, reject) => {
|
|
// Check for a cached ACK that arrived before we started waiting
|
|
const cached = this.pendingAcks.get(expectedIndex);
|
|
if (cached) {
|
|
this.pendingAcks.delete(expectedIndex);
|
|
logAck('WAIT', `pendingAcks hit for index=${expectedIndex} actual=${cached.index} — resolving immediately`);
|
|
resolve(cached);
|
|
return;
|
|
}
|
|
|
|
logAck('WAIT', `Registering resolver for index=${expectedIndex} timeout=${timeoutMs}ms`);
|
|
|
|
const timer = setTimeout(() => {
|
|
const entry = this.ackResolvers.get(expectedIndex);
|
|
if (entry) {
|
|
this.ackResolvers.delete(expectedIndex);
|
|
logAck('WAIT', `TIMEOUT after ${timeoutMs}ms for index=${expectedIndex} — rejecting`);
|
|
reject(new Error(`Timeout menunggu ACK untuk chunk ${expectedIndex}`));
|
|
} else {
|
|
logAck('WAIT', `Timeout fired but resolver already consumed for index=${expectedIndex} — ignoring`);
|
|
}
|
|
}, timeoutMs);
|
|
|
|
this.ackResolvers.set(expectedIndex, { resolve, reject, timer });
|
|
});
|
|
}
|
|
|
|
async startSerialMonitor(onData: (text: string) => void): Promise<void> {
|
|
if (!this.serialChar) throw new Error('Belum terhubung ke perangkat');
|
|
|
|
this.onSerialData = onData;
|
|
|
|
await this.serialChar.startNotifications();
|
|
this._onSerialNotification = (event: Event) => {
|
|
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
|
const dv = target.value!;
|
|
const value = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
|
const text = new TextDecoder().decode(value);
|
|
this.onSerialData?.(text);
|
|
};
|
|
this.serialChar.addEventListener('characteristicvaluechanged', this._onSerialNotification);
|
|
}
|
|
|
|
async sendSerialInput(text: string): Promise<void> {
|
|
if (!this.serialChar) throw new Error('Belum terhubung ke perangkat');
|
|
const encoder = new TextEncoder();
|
|
await this.serialChar.writeValueWithoutResponse(encoder.encode(text));
|
|
}
|
|
|
|
stopSerialMonitor(): void {
|
|
if (this.serialChar && this._onSerialNotification) {
|
|
this.serialChar.removeEventListener('characteristicvaluechanged', this._onSerialNotification);
|
|
this.serialChar.stopNotifications();
|
|
}
|
|
this._onSerialNotification = null;
|
|
this.onSerialData = null;
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.stopSerialMonitor();
|
|
if (this.flashingChar) {
|
|
this.flashingChar.stopNotifications();
|
|
}
|
|
this.server?.disconnect();
|
|
this.cleanup();
|
|
}
|
|
|
|
onDisconnected(callback: () => void): void {
|
|
this._onDisconnected = callback;
|
|
}
|
|
|
|
private cleanup(): void {
|
|
// Remove device-level event listeners
|
|
if (this.device && this._onDeviceDisconnect) {
|
|
this.device.removeEventListener('gattserverdisconnected', this._onDeviceDisconnect);
|
|
}
|
|
this._onDeviceDisconnect = null;
|
|
|
|
// Remove flashing notification listener
|
|
if (this.flashingChar && this._onFlashingNotification) {
|
|
this.flashingChar.removeEventListener('characteristicvaluechanged', this._onFlashingNotification);
|
|
}
|
|
this._onFlashingNotification = null;
|
|
|
|
// Remove serial notification listener
|
|
if (this.serialChar && this._onSerialNotification) {
|
|
this.serialChar.removeEventListener('characteristicvaluechanged', this._onSerialNotification);
|
|
}
|
|
this._onSerialNotification = null;
|
|
|
|
this.server = null;
|
|
this.service = null;
|
|
this.flashingChar = null;
|
|
this.serialChar = null;
|
|
this.device = null;
|
|
// Clear all pending ACK resolvers
|
|
for (const [idx, entry] of this.ackResolvers) {
|
|
clearTimeout(entry.timer);
|
|
}
|
|
this.ackResolvers.clear();
|
|
this.pendingAcks.clear();
|
|
this.onSerialData = null;
|
|
}
|
|
|
|
private hexToBinary(hexContent: string): Uint8Array {
|
|
const bytes: number[] = [];
|
|
let extendedAddress = 0;
|
|
|
|
for (const line of hexContent.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed[0] !== ':') continue;
|
|
|
|
const byteCount = parseInt(trimmed.substring(1, 3), 16);
|
|
const address = parseInt(trimmed.substring(3, 7), 16) + extendedAddress;
|
|
const recordType = parseInt(trimmed.substring(7, 9), 16);
|
|
|
|
if (recordType === 0x00) {
|
|
for (let i = 0; i < byteCount; i++) {
|
|
const byteOffset = 9 + i * 2;
|
|
if (byteOffset + 2 <= trimmed.length) {
|
|
const byteVal = parseInt(trimmed.substring(byteOffset, byteOffset + 2), 16);
|
|
bytes[address + i] = byteVal;
|
|
}
|
|
}
|
|
} else if (recordType === 0x04) {
|
|
extendedAddress = parseInt(trimmed.substring(9, 13), 16) << 16;
|
|
} else if (recordType === 0x01) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const result = new Uint8Array(bytes.length);
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
result[i] = bytes[i] ?? 0;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private crc32Table: Uint32Array | null = null;
|
|
|
|
private crc32(data: Uint8Array): number {
|
|
if (!this.crc32Table) {
|
|
this.crc32Table = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let c = i;
|
|
for (let j = 0; j < 8; j++) {
|
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
}
|
|
this.crc32Table[i] = c;
|
|
}
|
|
}
|
|
let crc = 0xFFFFFFFF;
|
|
for (let i = 0; i < data.length; i++) {
|
|
crc = this.crc32Table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8);
|
|
}
|
|
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
}
|
|
|
|
private uint32ToBytes(value: number): Uint8Array {
|
|
return new Uint8Array([
|
|
value & 0xFF,
|
|
(value >> 8) & 0xFF,
|
|
(value >> 16) & 0xFF,
|
|
(value >> 24) & 0xFF
|
|
]);
|
|
}
|
|
}
|