From 9dda8fe3e6a55c9f06734fb9e6d76c07823b62b4 Mon Sep 17 00:00:00 2001 From: a2nr Date: Tue, 30 Jun 2026 13:12:55 +0700 Subject: [PATCH] fix: event-driven poll loop for END ACK to work around Android BLE notification delivery issue on Qualcomm chipsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Race ackPromise against a polling loop that yields to the event loop every 200ms (vs a single blocking await that can miss notifications) - bump SW cache v5→v6 --- frontend/src/lib/services/ble-deployer.ts | 491 ++++++++++++++++++++++ frontend/static/sw.js | 2 +- 2 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/services/ble-deployer.ts diff --git a/frontend/src/lib/services/ble-deployer.ts b/frontend/src/lib/services/ble-deployer.ts new file mode 100644 index 0000000..4325287 --- /dev/null +++ b/frontend/src/lib/services/ble-deployer.ts @@ -0,0 +1,491 @@ +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 void; + reject: (reason: Error) => void; + timer: ReturnType; + }>(); + + /** ACKs that arrived before waitForAck was called, keyed by index. */ + private pendingAcks = new Map(); + + 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(promise: Promise, ms: number, label: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timeout: ${label} (${ms}ms)`)), ms); + }) + ]); + } + + async pair(): Promise { + 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 { + 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 { + 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 sometimes fails to deliver the + * notification through the normal characteristicvaluechanged handler + * while a writeValueWithResponse await is still in progress on some + * Qualcomm chipsets. Race the ackPromise against a poll loop that + * yields to the event loop every 200ms and checks pendingAcks (which + * the notification handler fills if the resolver path was missed). */ + if (cmd === CMD_END) { + const deadline = Date.now() + ackTimeout; + const pollPromise: Promise = new Promise(resolve => { + (async () => { + while (Date.now() < deadline) { + if (this.pendingAcks.has(index)) { + this.pendingAcks.delete(index); + const entry = this.ackResolvers.get(index); + if (entry) { + clearTimeout(entry.timer); + this.ackResolvers.delete(index); + } + console.log(`[BLE-CMD] END idx=${index}: ACK received via poll (pendingAcks)`); + resolve(); + return; + } + await new Promise(r => setTimeout(r, 200)); + } + console.log(`[BLE-CMD] END idx=${index}: poll deadline passed — ackPromise must win`); + resolve(); // resolve poll so the race falls through to ackPromise + })(); + }); + await Promise.race([ackPromise, pollPromise]); + console.log(`[BLE-CMD] END idx=${index}: ackPromise or poll resolved`); + return; + } + + await ackPromise; + console.log(`[BLE-CMD] ${cmdName} idx=${index}: ackPromise resolved OK`); + } + + private async sendCommandWithRetry(cmd: number, index: number, data: Uint8Array, chunkCRC: number): Promise { + 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); + payload.set([ + chunkCRC & 0xFF, (chunkCRC >> 8) & 0xFF, (chunkCRC >> 16) & 0xFF, (chunkCRC >> 24) & 0xFF + ], 4 + data.length); + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const ackPromise = this.waitForAck(index); + await this.flashingChar!.writeValueWithResponse(payload); + await ackPromise; + return; + } catch (err) { + if (attempt === MAX_RETRIES - 1) throw err; + await new Promise(r => setTimeout(r, 500)); + } + } + } + + private waitForAck(expectedIndex: number, timeoutMs: number = BLE_TIMEOUT_MS): Promise { + 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 { + 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 { + 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 + ]); + } +} diff --git a/frontend/static/sw.js b/frontend/static/sw.js index 2ae3d07..e59548b 100644 --- a/frontend/static/sw.js +++ b/frontend/static/sw.js @@ -1,5 +1,5 @@ // static/sw.js -const CACHE_VERSION = 'elemes-v1'; +const CACHE_VERSION = 'elemes-v6'; const STATIC_CACHE = `${CACHE_VERSION}-static`; const API_CACHE = `${CACHE_VERSION}-api`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;