From f8e92875c901081c39763b14800db2736bad9a41 Mon Sep 17 00:00:00 2001 From: a2nr Date: Fri, 17 Jul 2026 08:10:12 +0700 Subject: [PATCH] feat: add USBHardwareDeployer with STK500v1 protocol via Web Serial API --- frontend/src/lib/services/usb-deployer.ts | 639 ++++++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 frontend/src/lib/services/usb-deployer.ts diff --git a/frontend/src/lib/services/usb-deployer.ts b/frontend/src/lib/services/usb-deployer.ts new file mode 100644 index 0000000..f250ae7 --- /dev/null +++ b/frontend/src/lib/services/usb-deployer.ts @@ -0,0 +1,639 @@ +/* Ambient declarations for Web Serial API (not in TS 5.9 lib.dom.d.ts). */ +/* Minimal subset needed by USBHardwareDeployer. */ +/* Wrapped in declare global because the file is a module (has imports) */ +/* and we need these types to be globally visible. */ +declare global { + class SerialPort { + readonly readable: ReadableStream | null; + readonly writable: WritableStream | null; + open(options: SerialPortOpenOptions): Promise; + close(): Promise; + getInfo(): SerialPortInfo; + setSignals(signals: SerialPortSignals): Promise; + addEventListener( + type: 'connect' | 'disconnect', + listener: (event: SerialConnectionEvent) => void + ): void; + removeEventListener( + type: 'connect' | 'disconnect', + listener: (event: SerialConnectionEvent) => void + ): void; + } + + interface SerialPortOpenOptions { + baudRate: number; + dataBits: 8 | 7 | 6 | 5; + stopBits: 1 | 2; + parity: 'none' | 'even' | 'odd'; + flowControl: 'none' | 'hardware'; + } + + interface SerialPortInfo { + usbVendorId: number; + usbProductId: number; + } + + interface SerialPortSignals { + dataTerminalReady?: boolean; + requestToSend?: boolean; + } + + interface SerialConnectionEvent extends Event { + readonly port: SerialPort; + } + + interface SerialPortRequestOptions { + filters?: SerialPortFilter[]; + } + + interface SerialPortFilter { + usbVendorId?: number; + usbProductId?: number; + } + + interface Serial { + readonly onconnect: ((event: SerialConnectionEvent) => void) | null; + readonly ondisconnect: ((event: SerialConnectionEvent) => void) | null; + requestPort(options?: SerialPortRequestOptions): Promise; + addEventListener( + type: 'connect' | 'disconnect', + listener: (event: SerialConnectionEvent) => void + ): void; + removeEventListener( + type: 'connect' | 'disconnect', + listener: (event: SerialConnectionEvent) => void + ): void; + } + + interface Navigator { + readonly serial: Serial; + } +} + +import type { HardwareDeployer, DeployProgress } from '$types/deployer'; + +/* ── STK500v1 protocol constants ────────────────────────────────────── */ +const CRC_EOP = 0x20; +const STK_GET_SYNC = 0x30; +const STK_READ_SIGN = 0x75; +const STK_ENTER_PROGMODE = 0x50; +const STK_LOAD_ADDRESS = 0x55; +const STK_PROG_PAGE = 0x64; +const STK_LEAVE_PROGMODE = 0x51; + +const STK_INSYNC = 0x14; +const STK_OK = 0x10; +const STK_NOSYNC = 0x15; + +const ATMEGA328P_SIG_0 = 0x1e; +const ATMEGA328P_SIG_1 = 0x95; +const ATMEGA328P_SIG_2 = 0x0f; +const ATMEGA328P_FLASH_SIZE = 32768; +const ATMEGA328P_PAGE_SIZE = 128; + +const STK_SYNC_RETRIES = 10; +const STK_CMD_TIMEOUT_MS = 200; +const STK_PAGE_TIMEOUT_MS = 500; +const STK_LEAVE_TIMEOUT_MS = 100; + +/* ── Arduino VID/PID pairs for port filter ─────────────────────────── */ +const ARDUINO_VID_PIDS: SerialPortFilter[] = [ + { usbVendorId: 0x2341, usbProductId: 0x0043 }, + { usbVendorId: 0x2341, usbProductId: 0x0001 }, + { usbVendorId: 0x2341, usbProductId: 0x0243 }, + { usbVendorId: 0x2a03, usbProductId: 0x0043 }, + { usbVendorId: 0x0403, usbProductId: 0x6001 }, + { usbVendorId: 0x1a86, usbProductId: 0x7523 }, + { usbVendorId: 0x1a86, usbProductId: 0x5523 } +]; + +const FLASH_BAUD = 115200; +const SERIAL_MONITOR_BAUD = 9600; + +/* ── Helpers ──────────────────────────────────────────────────────────── */ +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/* ── USBHardwareDeployer ─────────────────────────────────────────────── */ +export class USBHardwareDeployer implements HardwareDeployer { + private port: SerialPort | null = null; + private reader: ReadableStreamDefaultReader | null = null; + private writer: WritableStreamDefaultWriter | null = null; + private keepReading = false; + private serialMonitorActive = false; + private _onDisconnected: (() => void) | null = null; + private _deviceName: string | null = null; + + /* Bound handler so we can removeEventListener on cleanup */ + private _boundDisconnectHandler: ((event: SerialConnectionEvent) => void) | null = null; + + /* ── HardwareDeployer interface ────────────────────────────── */ + + checkSupport(): boolean { + return typeof navigator !== 'undefined' && 'serial' in navigator; + } + + get isConnected(): boolean { + return this.port !== null; + } + + get deviceName(): string | null { + return this._deviceName; + } + + /* ── Pair / connect ────────────────────────────────────────── */ + + async pair(): Promise { + if (!this.checkSupport()) { + throw new Error( + 'Web Serial tidak didukung di browser ini. Gunakan Chrome/Edge.' + ); + } + + /* Clean up any previous session first */ + this.cleanup(); + + let port: SerialPort; + try { + port = await navigator.serial.requestPort({ + filters: ARDUINO_VID_PIDS + }); + } catch { + throw new Error('Pemilihan port dibatalkan'); + } + this.port = port; + + const info = port.getInfo(); + this._deviceName = `USB ${info.usbVendorId.toString(16)}:${info.usbProductId.toString(16)}`; + console.log('[USB] paired:', this._deviceName); + + await this.port.open({ + baudRate: FLASH_BAUD, + dataBits: 8, + stopBits: 1, + parity: 'none', + flowControl: 'none' + }); + console.log('[USB] port opened at', FLASH_BAUD); + + this.writer = this.port.writable!.getWriter(); + this.reader = this.port.readable!.getReader(); + + /* Listen for disconnect on navigator.serial */ + this._boundDisconnectHandler = (event: SerialConnectionEvent) => { + if (event.port === this.port) { + console.log('[USB] port disconnected event'); + this.cleanup(); + this._onDisconnected?.(); + } + }; + navigator.serial.addEventListener('disconnect', this._boundDisconnectHandler); + } + + /* ── DTR auto-reset ─────────────────────────────────────────── */ + + async resetArduino(): Promise { + if (!this.port) throw new Error('Belum terhubung ke perangkat'); + + console.log('[USB] resetArduino: DTR/RTS pulse'); + /* DTR low + RTS low → 10ms → both high → wait 50ms for optiboot */ + await this.port.setSignals({ + dataTerminalReady: false, + requestToSend: false + }); + await sleep(10); + await this.port.setSignals({ + dataTerminalReady: true, + requestToSend: true + }); + await sleep(50); + console.log('[USB] resetArduino: DTR pulse done'); + } + + /* ── Deploy ─────────────────────────────────────────────────── */ + + async deployBinary( + base64Binary: string, + onProgress: (p: DeployProgress) => void + ): Promise { + const buffer = decodeBase64(base64Binary); + if (buffer.length === 0) throw new Error('Buffer kosong'); + if (buffer.length > ATMEGA328P_FLASH_SIZE) { + throw new Error( + `Buffer ${buffer.length} bytes melebihi flash ${ATMEGA328P_FLASH_SIZE}` + ); + } + await this.flashBuffer(buffer, onProgress); + } + + async deployHex( + _hexContent: string, + _onProgress: (p: DeployProgress) => void + ): Promise { + throw new Error( + 'USB deployer uses deployBinary. Use deployBinary(base64) instead.' + ); + } + + /* ── Serial monitor ─────────────────────────────────────────── */ + + async startSerialMonitor(onData: (text: string) => void): Promise { + if (!this.port) throw new Error('Belum terhubung ke perangkat'); + + /* Release flash-mode reader/writer, then close port */ + await this.stopReaderWriter(); + await this.port.close(); + + /* Reopen at serial-monitor baud rate (9600 default) */ + await this.port.open({ + baudRate: SERIAL_MONITOR_BAUD, + dataBits: 8, + stopBits: 1, + parity: 'none', + flowControl: 'none' + }); + console.log('[USB-SERIAL] port opened at', SERIAL_MONITOR_BAUD); + + this.writer = this.port.writable!.getWriter(); + this.reader = this.port.readable!.getReader(); + this.serialMonitorActive = true; + this.keepReading = true; + + const decoder = new TextDecoder(); + this.readLoop(onData, decoder).catch((e) => + console.error('[USB-SERIAL] read loop error:', e) + ); + } + + private async readLoop( + onData: (text: string) => void, + decoder: TextDecoder + ): Promise { + while (this.keepReading && this.reader) { + try { + const { value, done } = await this.reader.read(); + if (done) break; + if (value && value.length > 0) { + const text = decoder.decode(value, { stream: true }); + onData(text); + } + } catch (e) { + if (this.keepReading) { + console.error('[USB-SERIAL] read error:', e); + } + break; + } + } + } + + stopSerialMonitor(): void { + console.log('[USB-SERIAL] stopSerialMonitor'); + this.serialMonitorActive = false; + this.keepReading = false; + } + + async sendSerialInput(text: string): Promise { + if (!this.writer) throw new Error('Belum terhubung ke perangkat'); + const encoder = new TextEncoder(); + await this.writer.write(encoder.encode(text)); + } + + async setBaudRate(baud: number): Promise { + if (!this.port) throw new Error('Belum terhubung ke perangkat'); + /* Web Serial cannot change baud without close+reopen */ + await this.stopReaderWriter(); + await this.port.close(); + await this.port.open({ + baudRate: baud, + dataBits: 8, + stopBits: 1, + parity: 'none', + flowControl: 'none' + }); + this.writer = this.port.writable!.getWriter(); + this.reader = this.port.readable!.getReader(); + console.log('[USB-SERIAL] baud rate changed to', baud); + } + + /* ── Disconnect / lifecycle ─────────────────────────────────── */ + + disconnect(): void { + this.stopSerialMonitor(); + this.cleanup(); + } + + onDisconnected(callback: () => void): void { + this._onDisconnected = callback; + } + + /* ── Private helpers ──────────────────────────────────────────── */ + + private async stopReaderWriter(): Promise { + this.keepReading = false; + try { + this.reader?.cancel(); + } catch { + /* ignore */ + } + this.reader = null; + try { + await this.writer?.close(); + } catch { + /* ignore */ + } + this.writer = null; + } + + private cleanup(): void { + if (this._boundDisconnectHandler) { + navigator.serial.removeEventListener( + 'disconnect', + this._boundDisconnectHandler + ); + this._boundDisconnectHandler = null; + } + this.keepReading = false; + this.serialMonitorActive = false; + this.stopReaderWriter(); + this.port?.close().catch(() => {}); + this.port = null; + this._deviceName = null; + } + + /* ── Timeout race helper ──────────────────────────────────────── */ + + 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) + ) + ]); + } + + /* ── STK500v1 protocol ───────────────────────────────────────── */ + /* */ + /* Every command is terminated with CRC_EOP=0x20. optiboot */ + /* replies with STK_INSYNC=0x14 immediately, then optional */ + /* payload bytes, then STK_OK=0x10. */ + /* ─────────────────────────────────────────────────────────────── */ + + /** + * Write a command, then expect: STK_INSYNC, optional payload, optional STK_OK. + * Returns the payload bytes (if any). + */ + private async sendAndExpect( + cmd: Uint8Array, + respLen: number, + timeoutMs: number, + strictOk: boolean + ): Promise { + if (!this.writer) throw new Error('Writer not available'); + + console.log( + `[USB] send: ${Array.from(cmd) + .map((b) => '0x' + b.toString(16).padStart(2, '0')) + .join(' ')}` + ); + + await this.withTimeout(this.writer.write(cmd), timeoutMs, 'write'); + console.log('[USB] write done'); + + /* Read STK_INSYNC (1 byte) */ + const insync = await this.readExact(1, timeoutMs); + console.log(`[USB] INSYNC: 0x${insync[0].toString(16).padStart(2, '0')}`); + + if (insync[0] === STK_NOSYNC) { + throw new Error('optiboot replied NOSYNC'); + } + if (insync[0] !== STK_INSYNC) { + throw new Error( + `expected INSYNC 0x14, got 0x${insync[0].toString(16)}` + ); + } + + /* Read optional payload */ + let resp: Uint8Array = new Uint8Array(0); + if (respLen > 0) { + resp = await this.readExact(respLen, timeoutMs); + } + + /* Read optional STK_OK */ + if (strictOk) { + const ok = await this.readExact(1, timeoutMs); + console.log(`[USB] STK_OK: 0x${ok[0].toString(16).padStart(2, '0')}`); + if (ok[0] !== STK_OK) { + throw new Error(`expected OK 0x10, got 0x${ok[0].toString(16)}`); + } + } + + return resp; + } + + /** + * Read exactly `len` bytes from the serial stream, with a deadline. + */ + private async readExact(len: number, timeoutMs: number): Promise { + const result = new Uint8Array(len); + let got = 0; + const deadline = Date.now() + timeoutMs; + while (got < len) { + const remaining = deadline - Date.now(); + if (remaining <= 0) + throw new Error(`read timeout: got ${got}/${len}`); + const { value, done } = await Promise.race([ + this.reader!.read(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('read timeout')), + remaining + ) + ) + ]); + if (done) throw new Error('stream closed'); + const copyLen = Math.min(value.length, len - got); + result.set(value.subarray(0, copyLen), got); + got += copyLen; + } + return result; + } + + /** + * Drain any stray bytes from the serial input (short timeout). + */ + private async drainStray(): Promise { + try { + const { value, done } = await Promise.race([ + this.reader!.read(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 10) + ) + ]); + if (!done && value && value.length > 0) { + console.log(`[USB] drained ${value.length} stray bytes`); + } + } catch { + /* nothing to drain — expected */ + } + } + + /* ── STK500v1 command implementations ─────────────────────────── */ + + private async getSync(): Promise { + const cmd = new Uint8Array([STK_GET_SYNC, CRC_EOP]); + + for (let attempt = 0; attempt < STK_SYNC_RETRIES; attempt++) { + try { + await this.sendAndExpect(cmd, 0, STK_CMD_TIMEOUT_MS, true); + console.log(`[USB] get_sync OK (attempt ${attempt + 1})`); + return; + } catch (e) { + console.log(`[USB] get_sync attempt ${attempt + 1} failed:`, e); + /* Drain stray bytes before retry */ + await this.drainStray(); + await sleep(20); + } + } + throw new Error( + `get_sync failed after ${STK_SYNC_RETRIES} attempts` + ); + } + + private async getSignature(): Promise { + const cmd = new Uint8Array([STK_READ_SIGN, CRC_EOP]); + const resp = await this.sendAndExpect(cmd, 3, STK_CMD_TIMEOUT_MS, true); + console.log( + `[USB] signature: ${Array.from(resp) + .map((b) => '0x' + b.toString(16).padStart(2, '0')) + .join(' ')}` + ); + return resp; + } + + private async enterProgmode(): Promise { + const cmd = new Uint8Array([STK_ENTER_PROGMODE, CRC_EOP]); + await this.sendAndExpect(cmd, 0, STK_CMD_TIMEOUT_MS, true); + console.log('[USB] entered programming mode'); + } + + private async loadAddress(wordAddr: number): Promise { + const cmd = new Uint8Array([ + STK_LOAD_ADDRESS, + wordAddr & 0xff, + (wordAddr >> 8) & 0xff, + CRC_EOP + ]); + await this.sendAndExpect(cmd, 0, STK_CMD_TIMEOUT_MS, true); + } + + private async progPage(data: Uint8Array): Promise { + const len = data.length; + /* Header: 0x64, len_hi, len_lo, memtype 'F'(0x46). Then data, then EOP. */ + const pkt = new Uint8Array(4 + len + 1); + pkt[0] = STK_PROG_PAGE; + pkt[1] = (len >> 8) & 0xff; + pkt[2] = len & 0xff; + pkt[3] = 0x46; /* 'F' = flash */ + pkt.set(data, 4); + pkt[4 + len] = CRC_EOP; + + await this.sendAndExpect(pkt, 0, STK_PAGE_TIMEOUT_MS, true); + } + + private async leaveProgmode(): Promise { + const cmd = new Uint8Array([STK_LEAVE_PROGMODE, CRC_EOP]); + /* optiboot shortens WDT and resets; STK_OK may be absent */ + try { + await this.sendAndExpect(cmd, 0, STK_LEAVE_TIMEOUT_MS, false); + } catch (e) { + console.log( + '[USB] leave_progmode did not reply (expected on optiboot):', + e + ); + /* Treat as success — optiboot intentionally WDT-resets */ + } + } + + /* ── Flash ─────────────────────────────────────────────────── */ + + private async flashBuffer( + buffer: Uint8Array, + onProgress: (p: DeployProgress) => void + ): Promise { + const pageSize = ATMEGA328P_PAGE_SIZE; + const totalPages = Math.ceil(buffer.length / pageSize); + + onProgress({ + state: 'pairing', + message: 'Merestart Arduino ke mode bootloader...', + totalChunks: totalPages, + completedChunks: 0 + }); + + /* 1. Auto-reset → optiboot */ + await this.resetArduino(); + + /* 2. Get sync (retry within optiboot ~1s window) */ + onProgress({ + state: 'flashing', + message: 'Sinkronisasi dengan bootloader...', + totalChunks: totalPages, + completedChunks: 0 + }); + await this.getSync(); + + /* 3. Read & validate signature */ + const sig = await this.getSignature(); + if ( + sig[0] !== ATMEGA328P_SIG_0 || + sig[1] !== ATMEGA328P_SIG_1 || + sig[2] !== ATMEGA328P_SIG_2 + ) { + throw new Error( + `signature mismatch: got ${sig[0].toString(16)} ${sig[1].toString(16)} ${sig[2].toString(16)}, ` + + `want ${ATMEGA328P_SIG_0.toString(16)} ${ATMEGA328P_SIG_1.toString(16)} ${ATMEGA328P_SIG_2.toString(16)}` + ); + } + + /* 4. Enter programming mode */ + await this.enterProgmode(); + + /* 5. Program pages (word addresses = byte/2) */ + for (let page = 0; page < totalPages; page++) { + const byteAddr = page * pageSize; + const remaining = buffer.length - byteAddr; + const thisLen = remaining > pageSize ? pageSize : remaining; + const wordAddr = byteAddr / 2; + + await this.loadAddress(wordAddr); + await this.progPage(buffer.slice(byteAddr, byteAddr + thisLen)); + + onProgress({ + state: 'flashing', + message: `Flashing page ${page + 1}/${totalPages}`, + totalChunks: totalPages, + completedChunks: page + 1 + }); + } + + /* 6. Leave programming mode */ + await this.leaveProgmode(); + + onProgress({ + state: 'success', + message: 'Flash berhasil!', + totalChunks: totalPages, + completedChunks: totalPages + }); + } +}