755 lines
23 KiB
TypeScript
755 lines
23 KiB
TypeScript
/* 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<Uint8Array> | null;
|
|
readonly writable: WritableStream<Uint8Array> | null;
|
|
open(options: SerialPortOpenOptions): Promise<void>;
|
|
close(): Promise<void>;
|
|
getInfo(): SerialPortInfo;
|
|
setSignals(signals: SerialPortSignals): Promise<void>;
|
|
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<SerialPort>;
|
|
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';
|
|
import { ByteStreamBuffer } from './byte-stream-buffer';
|
|
|
|
/* ── 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 = 6;
|
|
const STK_SYNC_TIMEOUT_MS = 300; /* Per-attempt read timeout during initial sync */
|
|
const STK_CMD_TIMEOUT_MS = 200;
|
|
const STK_PAGE_TIMEOUT_MS = 500;
|
|
const STK_LEAVE_TIMEOUT_MS = 100;
|
|
|
|
const BOOTLOADER_SETTLE_MS = 100; /* Wait after DTR release for optiboot to start */
|
|
const DRAIN_BYTE_TIMEOUT_MS = 10; /* How long to wait for the next stray byte */
|
|
const DRAIN_TOTAL_MS = 50; /* Max total time to spend draining */
|
|
|
|
/* ── 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<void>((r) => setTimeout(r, ms));
|
|
|
|
function decodeBase64(base64: string): Uint8Array {
|
|
try {
|
|
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;
|
|
} catch {
|
|
throw new Error('Invalid base64 input: expected a valid base64-encoded string');
|
|
}
|
|
}
|
|
|
|
/* ── USBHardwareDeployer ─────────────────────────────────────────────── */
|
|
export class USBHardwareDeployer implements HardwareDeployer {
|
|
private port: SerialPort | null = null;
|
|
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
|
private _onDisconnected: (() => void) | null = null;
|
|
private _deviceName: string | null = null;
|
|
private currentBaudRate: number | null = null;
|
|
private rxBuffer = new ByteStreamBuffer();
|
|
private pumpRunning = false;
|
|
private pumpDone: Promise<void> | 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<void> {
|
|
if (!this.checkSupport()) {
|
|
throw new Error(
|
|
'Web Serial tidak didukung di browser ini. Gunakan Chrome/Edge.'
|
|
);
|
|
}
|
|
|
|
/* Clean up any previous session first */
|
|
await 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'
|
|
});
|
|
this.currentBaudRate = FLASH_BAUD;
|
|
console.log('[USB] port opened at', FLASH_BAUD);
|
|
|
|
this.writer = this.port.writable!.getWriter();
|
|
this.reader = this.port.readable!.getReader();
|
|
this.rxBuffer.clear();
|
|
this.startPump();
|
|
|
|
/* Listen for disconnect on navigator.serial */
|
|
this._boundDisconnectHandler = async (event: SerialConnectionEvent) => {
|
|
if (event.port === this.port) {
|
|
console.log('[USB] port disconnected event');
|
|
await this.cleanup();
|
|
this._onDisconnected?.();
|
|
}
|
|
};
|
|
navigator.serial.addEventListener('disconnect', this._boundDisconnectHandler);
|
|
}
|
|
|
|
/* ── DTR auto-reset ─────────────────────────────────────────── */
|
|
|
|
async resetArduino(): Promise<void> {
|
|
if (!this.port) throw new Error('Belum terhubung ke perangkat');
|
|
|
|
console.log('[USB] resetArduino: DTR/RTS pulse');
|
|
/* DTR low + RTS low → 50ms → both high → wait for optiboot.
|
|
Longer low period (50ms vs 10ms) improves reliability on CH340 clones. */
|
|
await this.port.setSignals({
|
|
dataTerminalReady: false,
|
|
requestToSend: false
|
|
});
|
|
await sleep(50);
|
|
await this.port.setSignals({
|
|
dataTerminalReady: true,
|
|
requestToSend: true
|
|
});
|
|
await sleep(BOOTLOADER_SETTLE_MS);
|
|
console.log('[USB] resetArduino: DTR pulse done');
|
|
}
|
|
|
|
/* ── Deploy ─────────────────────────────────────────────────── */
|
|
|
|
async deployBinary(
|
|
base64Binary: string,
|
|
onProgress: (p: DeployProgress) => void
|
|
): Promise<void> {
|
|
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<void> {
|
|
throw new Error(
|
|
'USB deployer uses deployBinary. Use deployBinary(base64) instead.'
|
|
);
|
|
}
|
|
|
|
/* ── Serial monitor ─────────────────────────────────────────── */
|
|
|
|
async startSerialMonitor(onData: (text: string) => void): Promise<void> {
|
|
if (!this.port) throw new Error('Belum terhubung ke perangkat');
|
|
|
|
/* Stop pump + release reader/writer, reopen at monitor baud. */
|
|
this.rxBuffer.onData = null;
|
|
await this.stopPump();
|
|
await this.stopReaderWriter();
|
|
await this.port.close();
|
|
await this.port.open({
|
|
baudRate: SERIAL_MONITOR_BAUD,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: 'none',
|
|
flowControl: 'none'
|
|
});
|
|
this.currentBaudRate = SERIAL_MONITOR_BAUD;
|
|
console.log('[USB-SERIAL] port opened at', SERIAL_MONITOR_BAUD);
|
|
|
|
this.writer = this.port.writable!.getWriter();
|
|
this.reader = this.port.readable!.getReader();
|
|
this.rxBuffer.clear();
|
|
|
|
const decoder = new TextDecoder();
|
|
this.rxBuffer.onData = (chunk) => {
|
|
const text = decoder.decode(chunk, { stream: true });
|
|
if (text) onData(text);
|
|
};
|
|
this.startPump();
|
|
}
|
|
|
|
stopSerialMonitor(): void {
|
|
console.log('[USB-SERIAL] stopSerialMonitor');
|
|
this.rxBuffer.onData = null;
|
|
}
|
|
|
|
async sendSerialInput(text: string): Promise<void> {
|
|
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<void> {
|
|
if (!this.port) throw new Error('Belum terhubung ke perangkat');
|
|
const prevOnData = this.rxBuffer.onData;
|
|
/* Web Serial cannot change baud without close+reopen */
|
|
await this.stopPump();
|
|
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();
|
|
this.rxBuffer.clear();
|
|
this.rxBuffer.onData = prevOnData;
|
|
this.startPump();
|
|
console.log('[USB-SERIAL] baud rate changed to', baud);
|
|
this.currentBaudRate = baud;
|
|
}
|
|
|
|
/* ── Disconnect / lifecycle ─────────────────────────────────── */
|
|
|
|
async disconnect(): Promise<void> {
|
|
this.stopSerialMonitor();
|
|
await this.cleanup();
|
|
}
|
|
|
|
onDisconnected(callback: () => void): void {
|
|
this._onDisconnected = callback;
|
|
}
|
|
|
|
/* ── Private helpers ──────────────────────────────────────────── */
|
|
|
|
private async stopReaderWriter(): Promise<void> {
|
|
/* Pump owns reader.cancel via stopPump; here we just release locks. */
|
|
try {
|
|
this.reader?.releaseLock();
|
|
} catch {
|
|
/* reader may already be released/cancelled */
|
|
}
|
|
this.reader = null;
|
|
try {
|
|
await this.writer?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
this.writer = null;
|
|
}
|
|
|
|
/* ── Read pump: the ONLY consumer of reader.read() ──────────
|
|
* Feeds every incoming byte into rxBuffer. readExact/drain and the
|
|
* serial monitor consume from rxBuffer, never from reader.read().
|
|
* This eliminates orphaned-read races during STK500 sync. */
|
|
private startPump(): void {
|
|
if (this.pumpRunning) return;
|
|
if (!this.reader) throw new Error('startPump: no reader');
|
|
this.pumpRunning = true;
|
|
const reader = this.reader;
|
|
this.pumpDone = (async () => {
|
|
while (this.pumpRunning) {
|
|
try {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (value && value.length > 0) this.rxBuffer.push(value);
|
|
} catch (e) {
|
|
if (this.pumpRunning) {
|
|
console.error('[USB] pump read error:', e);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
/* Keep state accurate even if loop exits via {done} or error. */
|
|
this.pumpRunning = false;
|
|
})();
|
|
}
|
|
|
|
private async stopPump(): Promise<void> {
|
|
this.pumpRunning = false;
|
|
try {
|
|
await this.reader?.cancel();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
await this.pumpDone;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
this.pumpDone = null;
|
|
}
|
|
|
|
private async cleanup(): Promise<void> {
|
|
if (this._boundDisconnectHandler) {
|
|
navigator.serial.removeEventListener(
|
|
'disconnect',
|
|
this._boundDisconnectHandler
|
|
);
|
|
this._boundDisconnectHandler = null;
|
|
}
|
|
await this.stopPump();
|
|
await this.stopReaderWriter();
|
|
this.rxBuffer.clear();
|
|
this.rxBuffer.onData = null;
|
|
this.currentBaudRate = null;
|
|
try {
|
|
await this.port?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
this.port = null;
|
|
this._deviceName = null;
|
|
}
|
|
|
|
/* ── Timeout race helper ──────────────────────────────────────── */
|
|
|
|
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)
|
|
)
|
|
]);
|
|
}
|
|
|
|
/* ── 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<Uint8Array> {
|
|
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) {
|
|
console.log('[USB] optiboot replied NOSYNC');
|
|
throw new Error('optiboot replied NOSYNC');
|
|
}
|
|
if (insync[0] !== STK_INSYNC) {
|
|
console.log(
|
|
`[USB] expected INSYNC 0x${STK_INSYNC.toString(16)}, got 0x${insync[0].toString(16)}`
|
|
);
|
|
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 rxBuffer with a deadline.
|
|
* The pump is the sole reader.read() caller, so there is no orphaned-read
|
|
* race: bytes that arrive after a timeout stay buffered for the next call.
|
|
*/
|
|
private async readExact(len: number, timeoutMs: number): Promise<Uint8Array> {
|
|
try {
|
|
return await this.rxBuffer.readExact(len, timeoutMs);
|
|
} catch (e) {
|
|
const got = this.rxBuffer.length;
|
|
console.log(`[USB] readExact failed (${got} buffered): ${(e as Error).message}`);
|
|
/* Drop any partial/stale bytes so the next command starts clean.
|
|
The port baud hasn't changed here, so a clear is safe. */
|
|
this.rxBuffer.clear();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drain any buffered/stray bytes. With the pump running, stray bytes are
|
|
* already in rxBuffer, so draining is a synchronous clear plus a short
|
|
* settle window to absorb bytes still in flight.
|
|
*/
|
|
private async drainStray(): Promise<void> {
|
|
/* Absorb bytes still arriving: wait up to DRAIN_TOTAL_MS, clearing. */
|
|
const deadline = Date.now() + DRAIN_TOTAL_MS;
|
|
let drained = this.rxBuffer.readAvailable().length;
|
|
while (Date.now() < deadline) {
|
|
await sleep(DRAIN_BYTE_TIMEOUT_MS);
|
|
const more = this.rxBuffer.readAvailable().length;
|
|
if (more === 0) break;
|
|
drained += more;
|
|
}
|
|
if (drained > 0) console.log(`[USB] drained ${drained} stray bytes`);
|
|
}
|
|
|
|
/* ── STK500v1 command implementations ─────────────────────────── */
|
|
|
|
private async getSync(): Promise<void> {
|
|
const cmd = new Uint8Array([STK_GET_SYNC, CRC_EOP]);
|
|
|
|
for (let attempt = 0; attempt < STK_SYNC_RETRIES; attempt++) {
|
|
try {
|
|
/* Drain before sending to ensure clean channel */
|
|
await this.drainStray();
|
|
await this.sendAndExpect(cmd, 0, STK_SYNC_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();
|
|
/* Exponential backoff: 20ms, 40ms, 80ms, ... capped at 150ms */
|
|
const delay = Math.min(20 * Math.pow(2, attempt), 150);
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
throw new Error(`get_sync failed after ${STK_SYNC_RETRIES} attempts`);
|
|
}
|
|
|
|
private async getSignature(): Promise<Uint8Array> {
|
|
const cmd = new Uint8Array([STK_READ_SIGN, CRC_EOP]);
|
|
const resp = await this.sendAndExpect(cmd, 3, STK_CMD_TIMEOUT_MS, true);
|
|
if (resp.length < 3) {
|
|
throw new Error(
|
|
`signature response too short: got ${resp.length} bytes, expected 3`
|
|
);
|
|
}
|
|
console.log(
|
|
`[USB] signature: ${Array.from(resp)
|
|
.map((b) => '0x' + b.toString(16).padStart(2, '0'))
|
|
.join(' ')}`
|
|
);
|
|
return resp;
|
|
}
|
|
|
|
private async enterProgmode(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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
|
|
});
|
|
|
|
/* ── Ensure port is at flash baud rate ── */
|
|
this.rxBuffer.onData = null; /* detach serial monitor sink */
|
|
await this.stopPump();
|
|
await this.stopReaderWriter();
|
|
|
|
if (!this.port) {
|
|
throw new Error('Port tidak tersedia — hubungkan perangkat terlebih dahulu');
|
|
}
|
|
|
|
/* Reuse port if already at FLASH_BAUD. Closing+opening causes kernel
|
|
DTR toggle on CH340/CH341 clones, which can make the bootloader
|
|
exit before we get to resetArduino(). */
|
|
const needReopen = this.currentBaudRate !== FLASH_BAUD;
|
|
|
|
if (needReopen) {
|
|
try {
|
|
await this.port.close();
|
|
} catch {
|
|
/* may already be closed */
|
|
}
|
|
await this.port.open({
|
|
baudRate: FLASH_BAUD,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: 'none',
|
|
flowControl: 'none'
|
|
});
|
|
this.currentBaudRate = FLASH_BAUD;
|
|
console.log('[USB] flashBuffer: port reopened at', FLASH_BAUD);
|
|
} else {
|
|
console.log('[USB] flashBuffer: reusing existing port at', FLASH_BAUD);
|
|
}
|
|
|
|
this.writer = this.port.writable!.getWriter();
|
|
this.reader = this.port.readable!.getReader();
|
|
this.rxBuffer.clear();
|
|
this.startPump();
|
|
|
|
/* 1. Auto-reset → optiboot */
|
|
await this.resetArduino();
|
|
|
|
/* Drain stray bytes from prior serial session / boot chatter
|
|
before sync (mirrors firmware ESP32 drain_cdc). */
|
|
await this.drainStray();
|
|
|
|
/* 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)}`
|
|
);
|
|
}
|
|
|
|
/* Track progmode for safe error recovery (I2) */
|
|
let inProgmode = false;
|
|
|
|
try {
|
|
/* 4. Enter programming mode */
|
|
await this.enterProgmode();
|
|
inProgmode = true;
|
|
|
|
/* 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.subarray(byteAddr, byteAddr + thisLen));
|
|
|
|
onProgress({
|
|
state: 'flashing',
|
|
message: `Flashing page ${page + 1}/${totalPages}`,
|
|
totalChunks: totalPages,
|
|
completedChunks: page + 1
|
|
});
|
|
}
|
|
|
|
/* 6. Leave programming mode */
|
|
await this.leaveProgmode();
|
|
inProgmode = false;
|
|
|
|
onProgress({
|
|
state: 'success',
|
|
message: 'Flash berhasil!',
|
|
totalChunks: totalPages,
|
|
completedChunks: totalPages
|
|
});
|
|
} catch (e) {
|
|
/* Best-effort leave progmode to unstick the device (I2) */
|
|
if (inProgmode) {
|
|
try {
|
|
await this.leaveProgmode();
|
|
} catch {
|
|
/* give up */
|
|
}
|
|
}
|
|
onProgress({
|
|
state: 'error',
|
|
message: `Flash gagal: ${(e as Error).message}`,
|
|
totalChunks: totalPages,
|
|
completedChunks: 0
|
|
});
|
|
throw e;
|
|
}
|
|
}
|
|
}
|