From d0292c09d368bf8cf9753428469e36e1d5e523c6 Mon Sep 17 00:00:00 2001 From: mtoldi Date: Mon, 11 Aug 2025 13:35:30 +0200 Subject: [PATCH 01/11] version update after publish --- package-lock.json | 4 ++-- package.json | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf16526..6180918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "soldered-micropython-helper", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "soldered-micropython-helper", - "version": "0.1.3", + "version": "0.2.0", "dependencies": { "cheerio": "^1.1.0", "fuse.js": "^7.1.0", diff --git a/package.json b/package.json index 07c740f..513fce7 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Soldered MicroPython Helper", "publisher": "solderedelectronics", "description": "A MicroPython-focused helper for working with ESP-based boards directly inside Visual Studio Code.", - "version": "0.1.3", + "version": "0.2.0", "engines": { "vscode": "^1.100.0" }, @@ -51,7 +51,12 @@ }, "mp.savePromptMode": { "type": "string", - "enum": ["ask", "pc", "device", "both"], + "enum": [ + "ask", + "pc", + "device", + "both" + ], "default": "ask", "description": "How to save .py files when 'save to device on save' is disabled: always ask, only to PC, only to device, or to both." }, From ba8ecfeb332fe79e5b3a3af76b32de74a8c6fb6b Mon Sep 17 00:00:00 2001 From: Fran Fodor Date: Mon, 25 May 2026 16:26:14 +0200 Subject: [PATCH 02/11] refactor --- package-lock.json | 12 +- package.json | 5 + src/EspFlasherProvider.ts | 217 ++++++ src/extension.ts | 1242 +-------------------------------- src/handlers/fileHandler.ts | 52 ++ src/handlers/flashHandler.ts | 214 ++++++ src/handlers/moduleHandler.ts | 164 +++++ src/handlers/serialHandler.ts | 185 +++++ src/handlers/uploadHandler.ts | 202 ++++++ src/panel/index.html | 456 ++++++------ src/types.ts | 17 + src/utils/execUtils.ts | 55 ++ src/utils/portUtils.ts | 36 + 13 files changed, 1398 insertions(+), 1459 deletions(-) create mode 100644 src/EspFlasherProvider.ts create mode 100644 src/handlers/fileHandler.ts create mode 100644 src/handlers/flashHandler.ts create mode 100644 src/handlers/moduleHandler.ts create mode 100644 src/handlers/serialHandler.ts create mode 100644 src/handlers/uploadHandler.ts create mode 100644 src/types.ts create mode 100644 src/utils/execUtils.ts create mode 100644 src/utils/portUtils.ts diff --git a/package-lock.json b/package-lock.json index 6180918..5b691dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -447,9 +447,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -775,9 +775,9 @@ } }, "node_modules/undici": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", - "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 513fce7..184afbd 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,11 @@ "type": "boolean", "default": false, "description": "If enabled, upload to device as main.py instead of using the original filename." + }, + "mp.esptoolPath": { + "type": "string", + "default": "esptool", + "description": "Path to the esptool executable (e.g. 'esptool', 'esptool.py', or a full path like '/opt/homebrew/bin/esptool')." } } }, diff --git a/src/EspFlasherProvider.ts b/src/EspFlasherProvider.ts new file mode 100644 index 0000000..a4f7d49 --- /dev/null +++ b/src/EspFlasherProvider.ts @@ -0,0 +1,217 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { SerialPort } from 'serialport'; +import { ChildProcess } from 'child_process'; + +import { HandlerContext } from './types'; +import { startSerialMonitor, handleRunPythonFile, handleStopRunningCode } from './handlers/serialHandler'; +import { handleFlashFromWeb, handleFlashFirmware, fetchFirmwareList } from './handlers/flashHandler'; +import { handleListFiles, handleDeleteFile } from './handlers/fileHandler'; +import { handleUploadPython, handleUploadPythonAsIs, handleUploadPythonFromPc, handleOpenFileFromDevice } from './handlers/uploadHandler'; +import { handleFetchModule, handleGetCategories, handleGetModulesForCategory } from './handlers/moduleHandler'; + +export class EspFlasherViewProvider implements vscode.WebviewViewProvider { + + private mpRunProc: ChildProcess | null = null; + private _view?: vscode.WebviewView; + private outputChannel = vscode.window.createOutputChannel('ESP Output'); + private serialMonitor: SerialPort | null = null; + + constructor(private readonly context: vscode.ExtensionContext) {} + + /** + * Builds a HandlerContext object that always reflects current class state + * via getter properties for serialMonitor and mpRunProc. + */ + private getHandlerContext(): HandlerContext { + const self = this; + return { + postMessage: (msg: any) => self._view?.webview.postMessage(msg), + outputChannel: this.outputChannel, + get serialMonitor() { return self.serialMonitor; }, + setSerialMonitor: (s: SerialPort | null) => { self.serialMonitor = s; }, + get mpRunProc() { return self.mpRunProc; }, + setMpRunProc: (p: ChildProcess | null) => { self.mpRunProc = p; }, + extensionContext: this.context, + }; + } + + /** + * Triggers a file list refresh on the connected device. + * Called from extension.ts after mp.savePython uploads a file. + */ + public refreshFileListOnDevice(port: string): void { + this._view?.webview.postMessage({ command: 'triggerListFiles', port }); + } + + /** + * Returns the shared output channel for logging. + */ + public getOutputChannel(): vscode.OutputChannel { + return this.outputChannel; + } + + /** + * Fetches available serial ports and sends them to the webview. + * Also triggers an initial file list refresh on the first port found. + */ + private async refreshState(): Promise { + const ports = await SerialPort.list(); + this._view?.webview.postMessage({ + command: 'populatePorts', + ports: ports.map(p => p.path), + }); + if (ports.length > 0) { + this._view?.webview.postMessage({ + command: 'triggerListFiles', + port: ports[0].path, + }); + } + } + + /** + * Called when the webview is resolved and ready. + * Sets up HTML, sends initial port list, wires all message handlers. + */ + async resolveWebviewView(webviewView: vscode.WebviewView): Promise { + this._view = webviewView; + + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) this.refreshState(); + }); + + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.html = this.getHtml(); + + const ports = await SerialPort.list(); + webviewView.webview.postMessage({ + command: 'populatePorts', + ports: ports.map(p => p.path), + }); + + webviewView.webview.onDidReceiveMessage(async (message) => { + const { port } = message; + + // Persist last-used port for mp.savePython + if (port && typeof port === 'string' && port.trim() !== '') { + await this.context.globalState.update('mp.lastPort', port); + } + + // Guard: most commands need a port + const needsPort = !['flashFirmware', 'getPorts', 'getCategories', 'getModulesForCategory', 'getFirmwareOptions', 'requestRefresh', 'noop'].includes(message.command); + if (needsPort && (!port || typeof port !== 'string' || port.trim() === '')) { + this.outputChannel.appendLine(`[WARN] Ignoring ${message.command} - no port provided yet.`); + return; + } + + const ctx = this.getHandlerContext(); + + switch (message.command) { + + case 'flashFirmware': + await handleFlashFirmware(ctx, message); + break; + + case 'requestRefresh': + await this.refreshState(); + break; + + case 'uploadPython': + await handleUploadPython(ctx, message); + break; + + case 'listFiles': + handleListFiles(ctx, message); + break; + + case 'getFirmwareOptions': { + const firmwareList = await fetchFirmwareList(ctx); + this._view?.webview.postMessage({ command: 'setFirmwareOptions', options: firmwareList }); + break; + } + + case 'flashFromWeb': + if (!message.firmwareUrl || !message.port) { + vscode.window.showErrorMessage('Firmware URL and port are required for flashing.'); + return; + } + await handleFlashFromWeb(ctx, message.firmwareUrl, message.port); + this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'start' }); + break; + + case 'openFileFromDevice': + await handleOpenFileFromDevice(ctx, message); + break; + + case 'uploadPythonAsIs': + await handleUploadPythonAsIs(ctx, message); + break; + + case 'runPythonFile': + await handleRunPythonFile(ctx, message); + break; + + case 'stopRunningCode': + await handleStopRunningCode(ctx, message); + break; + + case 'getPorts': { + const availablePorts = await SerialPort.list(); + this._view?.webview.postMessage({ + command: 'populatePorts', + ports: availablePorts.map(p => p.path), + }); + break; + } + + case 'startSerialMonitor': + if (!message.port) { + vscode.window.showErrorMessage('Please select a port.'); + return; + } + startSerialMonitor(ctx, message.port); + break; + + case 'uploadPythonFromPc': + await handleUploadPythonFromPc(ctx, message); + break; + + case 'fetchModule': + await handleFetchModule(ctx, message); + break; + + case 'getCategories': + await handleGetCategories(ctx); + break; + + case 'getModulesForCategory': + await handleGetModulesForCategory(ctx, message); + break; + + case 'deleteFile': + handleDeleteFile(ctx, message); + break; + + case 'noop': + break; + + default: + this.outputChannel.appendLine(`[INFO] Unknown command from webview: ${message.command}`); + } + }); + } + + /** + * Reads the webview HTML from disk and injects the icon URI. + */ + private getHtml(): string { + const htmlPath = path.join(this.context.extensionPath, 'src', 'panel', 'index.html'); + let html = fs.readFileSync(htmlPath, 'utf8'); + const mpIconUri = this._view?.webview.asWebviewUri( + vscode.Uri.joinPath(this.context.extensionUri, 'resources', 'mp.svg') + ); + html = html.replace('{{mpIconUri}}', mpIconUri?.toString() || ''); + return html; + } +} diff --git a/src/extension.ts b/src/extension.ts index 09e4d50..02009c4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,103 +1,78 @@ // author Marko Toldi @Soldered -// VS Code API for interacting with the editor, showing messages, progress, etc. import * as vscode from 'vscode'; - -// Allows executing terminal/CLI commands like `mpremote` and `esptool` -import { exec, spawn } from 'child_process'; - -// Used to list available serial ports for device connection -import { SerialPort } from 'serialport'; - -// Node.js core modules for filesystem, paths, OS temp directory, HTTPS requests -import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import * as https from 'https'; - -// Cheerio is a fast, flexible jQuery-like HTML parser for scraping the Micropython firmware page -import * as cheerio from 'cheerio'; +import * as fs from 'fs'; +import { exec } from 'child_process'; +import { SerialPort } from 'serialport'; -// Fuse.js provides fuzzy searching to find the best matching firmware options based on user input -import Fuse from 'fuse.js'; +import { EspFlasherViewProvider } from './EspFlasherProvider'; +import { pickPort } from './utils/portUtils'; -// Called when the extension is activated (e.g. VS Code starts or user opens the view) export function activate(context: vscode.ExtensionContext) { - // Create and register the Webview provider (also holds the shared OutputChannel / serial monitor) const provider = new EspFlasherViewProvider(context); + context.subscriptions.push( vscode.window.registerWebviewViewProvider('espFlasherWebview', provider) ); - // Reuse the same output channel the webview/serial monitor uses const out = provider.getOutputChannel(); // Narrowed save targets type SaveMode = 'pc' | 'device' | 'both'; - // Setting can be either a concrete mode or "ask" (prompt the user) type SavePromptMode = 'ask' | SaveMode; /** - * Resolve the final SaveMode using extension settings. + * Resolves the final SaveMode from extension settings. * - If "save to device on save" is enabled, optionally also save locally. - * - Otherwise, respect "savePromptMode" (and prompt if it's "ask"). + * - Otherwise respects "savePromptMode" (and prompts if "ask"). */ const resolveSaveMode = async (cfg: vscode.WorkspaceConfiguration): Promise => { const saveToDeviceOnSave = cfg.get('mp.saveToDeviceOnSave', true); const alsoSaveLocally = cfg.get('mp.alsoSaveLocally', false); - // Fast-path: automatic device save (with optional local save) if (saveToDeviceOnSave) { return alsoSaveLocally ? 'both' : 'device'; } - // Otherwise, check prompt mode (may be "ask") let m = cfg.get('mp.savePromptMode', 'ask'); if (m === 'ask') { - // Ask the user how to save the current .py file const pick = await vscode.window.showQuickPick( [ - { label: 'πŸ’Ύ Save to PC', value: 'pc' as const }, - { label: '⬆️ Save to device', value: 'device' as const }, - { label: 'πŸ”€ Save to both', value: 'both' as const } + { label: 'Save to PC', value: 'pc' as const }, + { label: 'Save to device', value: 'device' as const }, + { label: 'Save to both', value: 'both' as const }, ], { placeHolder: 'Where do you want to save this .py?' } ); - if (!pick) throw new Error('cancelled'); // user dismissed the picker + if (!pick) throw new Error('cancelled'); return pick.value; } - // Already a concrete mode return m; }; /** * Command: mp.savePython - * - Different saves: - * - Save only to PC - * - Save only to device - * - Or both (depending on settings / prompt) - * - Uses last selected COM port (remembered by webview) for device uploads. - * - After device upload, tells the webview to refresh the file list. + * Triggered by Ctrl+S / Cmd+S on Python files. + * Saves to PC, device, or both based on settings. */ context.subscriptions.push( vscode.commands.registerCommand('mp.savePython', async () => { - out.appendLine('β–Ά mp.savePython invoked'); + out.appendLine('mp.savePython invoked'); out.show(true); - // Ensure there is an active Python editor; otherwise fall back to normal save const editor0 = vscode.window.activeTextEditor; if (!editor0 || editor0.document.languageId !== 'python') { - out.appendLine('No Python editor active β†’ falling back to normal save'); + out.appendLine('No Python editor active - falling back to normal save'); await vscode.commands.executeCommand('workbench.action.files.save'); return; } - // Read settings const cfg = vscode.workspace.getConfiguration(); const saveAsMain = cfg.get('mp.saveDeviceAsMain', false); - // Decide final mode (pc / device / both) let mode: SaveMode; try { mode = await resolveSaveMode(cfg); @@ -109,22 +84,20 @@ export function activate(context: vscode.ExtensionContext) { return; } vscode.window.showErrorMessage(`Save error: ${msg}`); - out.appendLine(`❌ Resolve mode error: ${msg}`); + out.appendLine(`[ERROR] Resolve mode error: ${msg}`); return; } - // Track the document reference (it may change after an "untitled" save) let doc = editor0.document; /** - * Ensure the file is persisted to disk and up-to-date. - * - For untitled: prompt "Save As", write buffer, open the saved file. - * - For titled: trigger normal save. - * Returns false if user cancels or no active editor afterwards. + * Ensures the file is persisted to disk. + * For untitled files: prompts Save As. + * For titled files: triggers normal save. */ const ensureOnDisk = async (): Promise => { if (doc.isUntitled) { - out.appendLine('Document is untitled β†’ prompting for save location…'); + out.appendLine('Document is untitled - prompting for save location...'); const uri = await vscode.window.showSaveDialog({ filters: { Python: ['py'] } }); if (!uri) { out.appendLine('User cancelled Save Dialog.'); @@ -139,10 +112,9 @@ export function activate(context: vscode.ExtensionContext) { await vscode.commands.executeCommand('workbench.action.files.save'); } - // Refresh doc reference (it may have changed if it was untitled) const ed = vscode.window.activeTextEditor; if (!ed) { - out.appendLine('❌ No active editor after save.'); + out.appendLine('[ERROR] No active editor after save.'); return false; } doc = ed.document; @@ -150,50 +122,13 @@ export function activate(context: vscode.ExtensionContext) { }; /** - * Pick a serial port to use for mpremote uploads. - * - Uses lastPort stored by the webview when you choose a port there. - * - If missing, quick-pick available ports and store the choice. - */ - const pickPort = async (): Promise => { - const lastPort = context.globalState.get('mp.lastPort'); - if (lastPort) { - out.appendLine(`Using last selected port: ${lastPort}`); - return lastPort; - } - - const ports = await SerialPort.list(); - if (!ports.length) { - vscode.window.showErrorMessage('No serial ports available.'); - out.appendLine('❌ No serial ports available.'); - return; - } - - const chosen = await vscode.window.showQuickPick( - ports.map(p => p.path), - { placeHolder: 'Select a serial port' } - ); - - if (chosen) { - await context.globalState.update('mp.lastPort', chosen); - out.appendLine(`Selected port: ${chosen} (saved as lastPort)`); - } else { - out.appendLine('User cancelled port selection.'); - } - return chosen || undefined; - }; - - /** - * Upload current buffer/file to the device via mpremote. - * - For "device" mode: writes buffer to temp and uploads (no local save). - * - For "both" mode: ensures local save, then uploads the saved file. - * - After upload, asks the webview to refresh device file list. + * Uploads the current file/buffer to the device via mpremote. */ const uploadToDevice = async () => { - const port = await pickPort(); + const port = await pickPort({ outputChannel: out, extensionContext: context }); if (!port) return; if (mode === 'device') { - // Write current buffer to a temp file, then upload to device const tmpDir = path.join(os.tmpdir(), 'mp-save'); await fs.promises.mkdir(tmpDir, { recursive: true }); @@ -201,51 +136,50 @@ export function activate(context: vscode.ExtensionContext) { const tmpPath = path.join(tmpDir, fname); await fs.promises.writeFile(tmpPath, doc.getText(), 'utf8'); - out.appendLine(`Uploading buffer β†’ temp: ${tmpPath} β†’ device:${saveAsMain ? 'main.py' : fname} on ${port}`); + out.appendLine(`Uploading buffer - temp: ${tmpPath} - device: ${fname} on ${port}`); await new Promise((resolve, reject) => { const cmd = `mpremote connect ${port} fs cp "${tmpPath}" :${saveAsMain ? 'main.py' : `"${fname}"`}`; out.appendLine(`Exec: ${cmd}`); exec(cmd, (err, _o, stderr) => { if (err) { - out.appendLine(`❌ Upload failed: ${stderr || err}`); + out.appendLine(`[ERROR] Upload failed: ${stderr || err}`); reject(new Error(stderr || String(err))); } else { - out.appendLine('βœ… Upload successful (device-only).'); + out.appendLine('Upload successful (device-only).'); resolve(); } }); }); - vscode.window.showInformationMessage(`βœ… Saved to device (${fname}).`); - (provider as any).refreshFileListOnDevice?.(port); // tell the webview to refresh files + vscode.window.showInformationMessage(`Saved to device (${fname}).`); + provider.refreshFileListOnDevice(port); return; } - // "pc" or "both": ensure saved to disk first, then upload that on-disk file const ok = await ensureOnDisk(); if (!ok) return; const localPath = doc.fileName; const deviceName = saveAsMain ? 'main.py' : path.basename(localPath); - out.appendLine(`Uploading disk file: ${localPath} β†’ device:${deviceName} on ${port}`); + out.appendLine(`Uploading disk file: ${localPath} - device: ${deviceName} on ${port}`); await new Promise((resolve, reject) => { const cmd = `mpremote connect ${port} fs cp "${localPath}" :${saveAsMain ? 'main.py' : `"${deviceName}"`}`; out.appendLine(`Exec: ${cmd}`); exec(cmd, (err, _o, stderr) => { if (err) { - out.appendLine(`❌ Upload failed: ${stderr || err}`); + out.appendLine(`[ERROR] Upload failed: ${stderr || err}`); reject(new Error(stderr || String(err))); } else { - out.appendLine('βœ… Upload successful (pc/both).'); + out.appendLine('Upload successful (pc/both).'); resolve(); } }); }); - vscode.window.showInformationMessage(`⬆️ Uploaded to device (${deviceName}).`); - (provider as any).refreshFileListOnDevice?.(port); // tell the webview to refresh files + vscode.window.showInformationMessage(`Uploaded to device (${deviceName}).`); + provider.refreshFileListOnDevice(port); }; // Execute the chosen flow @@ -254,7 +188,7 @@ export function activate(context: vscode.ExtensionContext) { const ok = await ensureOnDisk(); if (!ok) return; out.appendLine('Done: saved to PC only.'); - vscode.window.setStatusBarMessage('πŸ’Ύ Saved to PC', 1500); + vscode.window.setStatusBarMessage('Saved to PC', 1500); } else if (mode === 'device') { await uploadToDevice(); @@ -268,1114 +202,10 @@ export function activate(context: vscode.ExtensionContext) { } catch (e: any) { const msg = e?.message || String(e); vscode.window.showErrorMessage(`Save error: ${msg}`); - out.appendLine(`❌ Save flow error: ${msg}`); + out.appendLine(`[ERROR] Save flow error: ${msg}`); } }) ); } -// Called when the extension is deactivated (e.g. when VS Code shuts down or the extension is disabled) export function deactivate() {} - -/** - * Executes a shell command asynchronously. - * Wraps Node.js `exec` in a Promise so we can use async/await. - * - * @param command The shell command to execute. - * @returns Promise that resolves on success, rejects on error. - */ -function execCommand(command: string): Promise { - return new Promise((resolve, reject) => { - exec(command, (err, stdout, stderr) => { - // Log standard output if present - if (stdout) console.log(stdout); - - // Log standard error if present - if (stderr) console.error(stderr); - - // Reject the promise if an error occurred - if (err) { - reject(err); - } else { - // Resolve the promise on success - resolve(); - } - }); - }); -} - - -// Class that provides the Webview View for the ESP Flasher extension. -// It also manages communication between the webview (frontend) and extension (backend), -// and exposes utility methods like refreshing the file list and accessing the output channel. -class EspFlasherViewProvider implements vscode.WebviewViewProvider { - - private mpRunProc: import('child_process').ChildProcess | null = null; - - - // Holds the reference to the current webview instance - private _view?: vscode.WebviewView; - - // Dedicated output channel for logging ESP-related operations - private outputChannel = vscode.window.createOutputChannel("ESP Output"); - - // Store the extension context for later use (e.g., accessing globalState, resources) - constructor(private readonly context: vscode.ExtensionContext) {} - - /** - * Sends a message to the webview to trigger file listing on the connected device. - * This is typically called after uploading a file, so the UI updates automatically. - * - * @param port - The serial port of the connected MicroPython device - */ - public refreshFileListOnDevice(port: string) { - this._view?.webview.postMessage({ command: 'triggerListFiles', port }); - } - - /** - * Returns the shared output channel used for logging extension events and actions. - * Can be used from other parts of the extension to keep logs in one place. - * - * @returns The output channel instance. - */ - public getOutputChannel(): vscode.OutputChannel { - return this.outputChannel; - } - - -/** - * Handles firmware flashing when triggered from the Webview. - * Supports both UF2 (RP boards) and .bin (ESP32) firmware formats. - * - * @param firmwareUrl - The URL of the firmware to flash. - * @param port - The serial port of the connected device (ESP32 only). - */ -private async handleFlashFromWeb(firmwareUrl: string, port: string) { - const firmwareName = path.basename(firmwareUrl); // Extract file name from URL - const tmpPath = path.join(os.tmpdir(), firmwareName); // Path to temporarily store firmware - const isUF2 = firmwareUrl.endsWith('.uf2'); // Detect UF2 format - - // 1. Download firmware file to temp directory - await this.downloadFile(firmwareUrl, tmpPath); - - if (isUF2) { - // --- UF2 flashing (RP2040 / RP2350 boards) --- - const selectedFolder = await vscode.window.showOpenDialog({ - canSelectFolders: true, - canSelectFiles: false, - openLabel: 'Select RP drive (mass storage)', - }); - - // If user cancels folder selection - if (!selectedFolder || selectedFolder.length === 0) { - vscode.window.showWarningMessage('Firmware flash cancelled (no folder selected).'); - this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'error' }); - return; - } - - const dest = path.join(selectedFolder[0].fsPath, firmwareName); - - vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Flashing firmware...', - cancellable: false, - }, - () => - new Promise((resolve, reject) => { - // Notify webview that flashing has started - this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'start' }); - this.outputChannel.appendLine(`πŸ“€ Copying UF2 to: ${dest}`); - - try { - // Copy UF2 file to selected mass storage device - fs.copyFileSync(tmpPath, dest); - vscode.window.showInformationMessage('UF2 firmware copied successfully!'); - this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'done' }); - this.outputChannel.appendLine(`βœ… UF2 copied to: ${dest}, unplug and replug your board now`); - resolve(); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to copy UF2 file: ${err.message}`); - this.outputChannel.appendLine(`❌ Failed to copy UF2: ${err.message}`); - this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'error' }); - reject(err); - } - }) - ); - - } else { - // --- ESP32 flashing using esptool.py --- - const command = `python -u -m esptool --port ${port} --baud 115200 write_flash --flash_mode keep --flash_size keep --erase-all 0x1000 "${tmpPath}"`; - this.outputChannel.appendLine(`πŸ“€ Executing: ${command}`); - - vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Flashing firmware...', - cancellable: false, - }, - () => - new Promise((resolve, reject) => { - exec(command, (err, stdout, stderr) => { - // Show command output in Output Channel - this.outputChannel.appendLine('[stdout]'); - this.outputChannel.appendLine(stdout); - if (stderr) { - this.outputChannel.appendLine('[stderr]'); - this.outputChannel.appendLine(stderr); - } - - if (err) { - // Flashing failed - vscode.window.showErrorMessage(`Flash failed: ${stderr || err.message}`); - reject(err); - this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'error' }); - } else { - // Flashing succeeded - vscode.window.showInformationMessage('Flash successful!'); - resolve(); - this._view?.webview.postMessage({ command: 'flashStatusUpdate', text: 'done' }); - this._view?.webview.postMessage({ command: 'triggerListFiles', port }); - } - }); - }) - ); - } -} - -/** - * Fetches the latest MicroPython firmware list for supported boards. - * Scans micropython.org download pages for ESP32, RP2040, and RP2350 slugs. - * - * @returns Array of firmware objects containing name, URL, board type, and version. - */ -private async fetchFirmwareList(): Promise<{ name: string, url: string, boardType: string, version: string }[]> { - const baseUrl = 'https://micropython.org'; - - // Board slugs for each platform type - const esp32Slugs = ['ESP32_GENERIC', 'ESP32_GENERIC_C3', 'ESP32_GENERIC_C2', 'ESP32_GENERIC_C6', 'ESP32_GENERIC_S2', 'ESP32_GENERIC_S3']; - const rp2040Slugs = ['ARDUINO_NANO_RP2040_CONNECT', 'SPARKFUN_PROMICRO', 'RPI_PICO']; - const rp2350Slugs = ['RPI_PICO2', 'RPI_PICO2_W', 'SPARKFUN_PROMICRO_RP2350']; - - // Will store all discovered firmwares - const allFirmwares: { name: string, url: string, boardType: string, version: string }[] = []; - - /** - * Helper to fetch and parse firmware download links for a given board slug. - * - * @param slug - Board identifier slug used in the micropython.org URL. - * @param extension - Expected firmware file extension (".bin" or ".uf2"). - * @param boardType - Friendly board type label ("ESP32", "RP2040", "RP2350"). - */ - const fetchForSlug = (slug: string, extension: string, boardType: string) => { - return new Promise((resolve) => { - const fullUrl = `${baseUrl}/download/${slug}/`; - - // Request the board's download page - https.get(fullUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => { - let data = ''; - - // Accumulate HTML content - res.on('data', chunk => data += chunk); - - res.on('end', () => { - const $ = cheerio.load(data); - let found = false; - - // Scan all anchor tags for matching firmware links - $('a').each((_, el) => { - if (found) return; // Only store the first match - - const href = $(el).attr('href'); - if ( - href && - href.endsWith(extension) && // Correct file extension - href.includes('/resources/firmware/') && // Correct folder path - /v\d+\.\d+\.\d+/.test(href) && // Matches version pattern vX.Y.Z - !/(spiram|psram|ota|preview|test|IDF)/i.test(href) // Exclude unwanted builds - ) { - const full = href.startsWith('http') ? href : `${baseUrl}${href}`; - - // Extract version from the filename or URL - const versionMatch = href.match(/v(\d+\.\d+\.\d+)/); - const version = versionMatch ? versionMatch[1] : 'unknown'; - - // Store firmware info - allFirmwares.push({ - name: path.basename(full), - url: full, - boardType, - version - }); - - found = true; - } - }); - - resolve(); - }); - }).on('error', err => { - // Log but don't fail the entire process - this.outputChannel.appendLine(`⚠️ Failed to fetch ${fullUrl}: ${err.message}`); - resolve(); - }); - }); - }; - - // Fetch firmwares for all board types in parallel - await Promise.all([ - ...esp32Slugs.map(slug => fetchForSlug(slug, '.bin', 'ESP32')), - ...rp2040Slugs.map(slug => fetchForSlug(slug, '.uf2', 'RP2040')), - ...rp2350Slugs.map(slug => fetchForSlug(slug, '.uf2', 'RP2350')), - ]); - - return allFirmwares; -} - -/** - * Downloads a file from the given HTTPS URL and saves it to the specified destination path. - * - * @param url - The URL of the file to download. - * @param dest - The local file system path where the file should be saved. - * @returns Promise that resolves when the file is successfully downloaded. - */ -private async downloadFile(url: string, dest: string): Promise { - return new Promise((resolve, reject) => { - // Create a writable stream for the destination file - const file = fs.createWriteStream(dest); - - // Start an HTTPS GET request for the file - https.get(url, response => { - // Pipe the response stream directly into the file stream - response.pipe(file); - - // When the file has finished writing - file.on('finish', () => { - // Close the file stream to flush all data to disk - file.close(err => { - if (err) { - reject(err); // Reject if closing the file fails - } else { - resolve(); // Resolve successfully - } - }); - }); - - }).on('error', err => { - // On error, remove the partially downloaded file and reject - fs.unlink(dest, () => reject(err)); - }); - }); -} - -// Holds the active SerialPort instance for the live monitor -private serialMonitor: SerialPort | null = null; - -/** - * Starts the serial monitor for the given COM port path. - * Streams all incoming serial data directly into the extension's output channel. - * - * @param portPath - The system path of the serial port (e.g., "COM3" or "/dev/ttyUSB0") - */ -private startSerialMonitor(portPath: string) { - // Close any existing monitor before starting a new one - if (this.serialMonitor) { - this.serialMonitor.close(); - this.serialMonitor = null; - } - - // Log opening action - this.outputChannel.appendLine(`πŸ”Œ Opening serial monitor on ${portPath}`); - - // Initialize the serial port - this.serialMonitor = new SerialPort({ - path: portPath, - baudRate: 115200, // Adjust if your microcontroller uses a different baud rate - autoOpen: true, - }); - - // Handle incoming serial data - this.serialMonitor.on('data', (data: Buffer) => { - const text = data.toString('utf-8'); - this.outputChannel.append(text); // Append as-is, without trimming newlines - }); - - // Handle serial port errors - this.serialMonitor.on('error', err => { - this.outputChannel.appendLine(`Serial error: ${err.message}`); - }); - - // Log when the monitor is closed - this.serialMonitor.on('close', () => { - this.outputChannel.appendLine(`πŸ”Œ Serial monitor closed.`); - }); -} - -/** - * Stops the serial monitor (if running) and sends a reset command to the device. - * This is useful to stop execution of main.py or reboot the board. - * - * @param portPath - The system path of the serial port to reset - */ -private stopSerialMonitorAndReset(portPath: string) { - // Close the serial monitor if it's currently open - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.serialMonitor.close(err => { - if (err) { - this.outputChannel.appendLine(`❌ Error closing serial monitor: ${err.message}`); - } - }); - this.serialMonitor = null; - } - - // Optional: Send a reset command to the device - const resetCmd = `mpremote connect ${portPath} soft-reset`; - exec(resetCmd, (err, _stdout, stderr) => { - if (err) { - this.outputChannel.appendLine(`❌ Error resetting device: ${stderr || err.message}`); - } else { - this.outputChannel.appendLine(`πŸ” Device reset successfully.`); - } - }); -} - -/** - * Refreshes the extension's state by: - * 1. Fetching the list of available serial ports. - * 2. Sending the port list to the webview for UI population. - * 3. Automatically triggering a file list refresh for the first detected port. - */ -private async refreshState() { - // Get the list of available serial ports - const ports = await SerialPort.list(); - - // Send port list to the webview so it can populate the COM port dropdown - this._view?.webview.postMessage({ - command: 'populatePorts', - ports: ports.map(p => p.path), - }); - - // If at least one port exists, trigger a file list refresh for the first one - if (ports.length > 0) { - this._view?.webview.postMessage({ - command: 'triggerListFiles', - port: ports[0].path, - }); - } -} - -/** - * Called when the Webview view is resolved and ready to be displayed. - * Sets up HTML, sends initial data, and wires message listeners. - */ -async resolveWebviewView(webviewView: vscode.WebviewView): Promise { - // Keep a reference to the view so we can post messages later - this._view = webviewView; - - // Refresh state when the view becomes visible again - webviewView.onDidChangeVisibility(() => { - if (webviewView.visible) { - this.refreshState(); - } - }); - - // Allow JavaScript in the webview - webviewView.webview.options = { enableScripts: true }; - - // Load the webview HTML - webviewView.webview.html = this.getHtml(); - - // Discover available serial ports (e.g., COM3, /dev/ttyUSB0) - const ports = await SerialPort.list(); - - // Send the initial port list to the frontend - webviewView.webview.postMessage({ - command: 'populatePorts', - ports: ports.map(p => p.path), - }); - - // If any ports are available, auto-list files on the first one and start serial - if (ports.length > 0) { - const defaultPort = ports[0].path; - - webviewView.webview.postMessage({ - command: 'triggerListFiles', - port: defaultPort, - }); - - // βœ… Autostart serial monitor on the first port - this.startSerialMonitor(defaultPort); - this.outputChannel.show(); - } - - // Handle messages coming from the webview (frontend) - webviewView.webview.onDidReceiveMessage(async (message) => { - const { port } = message; - - // Remember last selected port so mp.savePython can reuse it - if (port && typeof port === 'string' && port.trim() !== '') { - await this.context.globalState.update('mp.lastPort', port); - } - - // Some commands don't require a port; all others do - const needsPort = !['flashFirmware', 'getPorts', 'searchModules'].includes(message.command); - if (needsPort && (!port || typeof port !== 'string' || port.trim() === '')) { - // Avoid noisy errors during startup or before UI finishes initializing - this.outputChannel.appendLine(`⚠ Ignoring ${message.command} - no port provided yet.`); - return; - } - - // Route by command (behavior unchanged) - switch (message.command) { - // ---- Firmware flashing from a local .bin file ---- - case 'flashFirmware': { - const fileUri = await vscode.window.showOpenDialog({ - filters: { 'BIN files': ['bin'] }, - canSelectMany: false, - }); - if (!fileUri) { - vscode.window.showErrorMessage('No firmware file selected.'); - return; - } - - const firmwarePath = fileUri[0].fsPath; - // Keep --chip generic for broader compatibility - const cmd = `python -u -m esptool --port ${message.port} --baud 115200 write_flash --flash_mode keep --flash_size keep --erase-all 0x1000 "${firmwarePath}"`; - - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Flashing firmware...', cancellable: false }, - () => - new Promise((resolve, reject) => { - // Notify frontend that flashing has started - this._view?.webview.postMessage({ command: 'uploadStatusUpdate', text: 'start' }); - - exec(cmd, (error, stdout, stderr) => { - console.log('Command:', cmd); - console.log('STDOUT:', stdout); - console.log('STDERR:', stderr); - - if (error) { - vscode.window.showErrorMessage(`Firmware flashing failed: ${stderr || error.message}`); - this._view?.webview.postMessage({ command: 'uploadStatusUpdate', text: 'error' }); - reject(error); - } else { - vscode.window.showInformationMessage('Firmware flashed successfully!'); - this._view?.webview.postMessage({ command: 'uploadStatusUpdate', text: 'done' }); - resolve(); - } - }); - }) - ); - break; - } - - // ---- Full UI refresh request (ports + initial file list) ---- - case 'requestRefresh': { - await this.refreshState(); - break; - } - - // ---- Upload active editor file to device as main.py ---- - case 'uploadPython': { - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.outputChannel.appendLine('Stopping serial monitor before proceeding...'); - this.serialMonitor.close(); - this.serialMonitor = null; - } - - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor || activeEditor.document.languageId !== 'python') { - vscode.window.showErrorMessage('No active Python file to upload.'); - return; - } - - const filePath = activeEditor.document.fileName; - const uploadCmd = `mpremote connect ${port} fs cp "${filePath}" :main.py`; - - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Uploading Python file as main.py...', cancellable: false }, - () => - new Promise((resolve, reject) => { - exec(uploadCmd, (uploadError, _stdout, uploadStderr) => { - if (uploadError) { - vscode.window.showErrorMessage(`Upload failed: ${uploadStderr || uploadError.message}`); - reject(uploadError); - return; - } - - vscode.window.showInformationMessage('Python file uploaded successfully as main.py!'); - // Ask the webview to refresh the device file list - this._view?.webview.postMessage({ command: 'triggerListFiles', port: message.port }); - resolve(); - }); - }) - ); - break; - } - - // ---- List files stored on the device ---- - case 'listFiles': { - const listCmd = `mpremote connect ${port} exec "import os; print(os.listdir())"`; - exec(listCmd, (err, stdout, stderr) => { - if (err) { - vscode.window.showErrorMessage(`Failed to list files: ${stderr || err.message}`); - return; - } - - try { - // Extract the Python list literal from stdout and parse it - const match = stdout.match(/\[[\s\S]*?\]/); - const files = match ? JSON.parse(match[0].replace(/'/g, '"')) : []; - this._view?.webview.postMessage({ command: 'displayFiles', files }); - } catch (_e) { - vscode.window.showErrorMessage('Failed to parse file list.'); - } - }); - break; - } - - // ---- Search firmware options by name (fuzzy) ---- - case 'getFirmwareOptions': { - const firmwareList = await this.fetchFirmwareList(); - - const fuse = new Fuse(firmwareList, { - keys: ['name'], - threshold: 0.4, // lower = stricter - }); - - const matches = fuse.search(message.board || ''); - const filtered = matches.slice(0, 10).map(m => m.item); - - this._view?.webview.postMessage({ - command: 'setFirmwareOptions', - options: filtered, - }); - break; - } - - // ---- Flash firmware directly from a selected URL (web) ---- - case 'flashFromWeb': { - const { firmwareUrl, port } = message; - if (!firmwareUrl || !port) { - vscode.window.showErrorMessage('Firmware URL and port are required for flashing.'); - return; - } - - await this.handleFlashFromWeb(firmwareUrl, port); - - // Tell the UI flashing has started (keep existing behavior) - this._view?.webview.postMessage({ - command: 'flashStatusUpdate', - text: 'start', - }); - break; - } - - // ---- Download a device file to temp and open in editor ---- - case 'openFileFromDevice': { - const { port, filename } = message; - const tempDir = path.join(os.tmpdir(), 'esp-temp'); - const localPath = path.join(tempDir, filename); - - try { - await fs.promises.mkdir(tempDir, { recursive: true }); - const cmd = `mpremote connect ${port} fs cp :"${filename}" "${localPath}"`; - - this.outputChannel.appendLine(`πŸ“₯ Downloading ${filename} from device...`); - exec(cmd, async (err, _stdout, stderr) => { - if (err) { - vscode.window.showErrorMessage(`Failed to download file: ${stderr || err.message}`); - return; - } - - const doc = await vscode.workspace.openTextDocument(localPath); - await vscode.window.showTextDocument(doc, { preview: false }); - vscode.window.showInformationMessage(`Opened ${filename} from device.`); - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`Failed to prepare file for editing: ${msg}`); - } - break; - } - - // ---- Upload active editor file preserving its original filename ---- - case 'uploadPythonAsIs': { - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.outputChannel.appendLine('πŸ›‘ Stopping serial monitor before proceeding...'); - this.serialMonitor.close(); - this.serialMonitor = null; - } - - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor || activeEditor.document.languageId !== 'python') { - vscode.window.showErrorMessage('No active Python file to upload.'); - return; - } - - const filePath = activeEditor.document.fileName; - const fileName = filePath.split(/[/\\]/).pop()!; - const uploadCmd = `mpremote connect ${message.port} fs cp "${filePath}" :"${fileName}"`; - - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: `Uploading ${fileName} to device...`, cancellable: false }, - () => - new Promise((resolve, reject) => { - exec(uploadCmd, (uploadError, _stdout, uploadStderr) => { - if (uploadError) { - vscode.window.showErrorMessage(`Upload failed: ${uploadStderr || uploadError.message}`); - reject(uploadError); - return; - } - - vscode.window.showInformationMessage(`${fileName} uploaded successfully!`); - // Refresh file list in the webview - this._view?.webview.postMessage({ command: 'triggerListFiles', port: message.port }); - resolve(); - }); - }) - ); - break; - } - - // ---- Run (no main.py, no reset; stream output) ---- - case 'runPythonFile': { - // Free the port from serial monitor - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.outputChannel.appendLine('πŸ›‘ Stopping serial monitor before proceeding...'); - this.serialMonitor.close(); - this.serialMonitor = null; - } - - // If a previous run is active, kill it - if (this.mpRunProc) { - try { this.mpRunProc.kill(); } catch {} - this.mpRunProc = null; - } - - const { filename, port } = message; - if (!filename || !port) { - vscode.window.showErrorMessage('Filename and port are required to run the script.'); - this.outputChannel.appendLine('⚠️ Cannot run script: filename or port not provided.'); - return; - } - - // 1) Download the on-device file to a temp local path - const tempPath = path.join(os.tmpdir(), `__run_tmp__-${path.basename(filename)}`); - const downloadCmd = `mpremote connect ${port} fs cp :${filename} "${tempPath}"`; - - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: `Preparing ${filename}...`, cancellable: false }, - async () => { - //this.outputChannel.appendLine(`⬇ Downloading ${filename} to temp: ${tempPath}`); - await execCommand(downloadCmd); - } - ); - - // 2) Run without writing main.py or resetting; stream output live - this.outputChannel.appendLine(`β–Ά Running ${filename}`); - this.outputChannel.show(true); - - const args = ['connect', port, 'run', tempPath]; - const child = spawn('mpremote', args, { shell: false }); - - this.mpRunProc = child; - - child.stdout.on('data', (data: Buffer) => { - this.outputChannel.append(data.toString('utf-8')); - }); - child.stderr.on('data', (data: Buffer) => { - this.outputChannel.append(data.toString('utf-8')); - }); - child.on('close', (code) => { - this.outputChannel.appendLine(`\n[run exited with code ${code ?? 0}]`); - this.mpRunProc = null; - }); - child.on('error', (err) => { - vscode.window.showErrorMessage(`Failed to start mpremote run: ${err.message}`); - this.outputChannel.appendLine(`❌ Failed to start mpremote run: ${err.message}`); - this.mpRunProc = null; - }); - - vscode.window.showInformationMessage(`${filename} is running. Use β€œStop” to interrupt.`); - break; - } - - case 'stopRunningCode': { - const { port, keepMonitor } = message as { port?: string; keepMonitor?: boolean }; - if (!port) { - vscode.window.showErrorMessage('Port is required to stop running code.'); - return; - } - - // 1) Stop any active 'mpremote run' process - const killProc = (proc: import('child_process').ChildProcess, timeoutMs = 800) => - new Promise((resolve) => { - let done = false; - const finish = () => { if (!done) { done = true; resolve(); } }; - try { proc.kill('SIGINT'); } catch {} - const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} finish(); }, timeoutMs); - proc.on('exit', () => { clearTimeout(t); finish(); }); - proc.on('close', () => { clearTimeout(t); finish(); }); - proc.on('error', () => { clearTimeout(t); finish(); }); - }); - - if (this.mpRunProc) { - await killProc(this.mpRunProc); - this.mpRunProc = null; - } - - // 2) Close serial monitor to free the port (we'll optionally reopen later) - const wantReopen = !!keepMonitor; - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.serialMonitor.close(); - this.serialMonitor = null; - } - - // Short grace so the OS releases the port handle - await new Promise(r => setTimeout(r, 150)); - - // 3) Send Ctrl-C, Ctrl-C, Ctrl-D directly over serial (interrupt + soft reset) - try { - await new Promise((resolve, reject) => { - const tmp = new SerialPort({ path: port, baudRate: 115200, autoOpen: false }); - tmp.open(err => { - if (err) return reject(err); - const seq = Buffer.from([0x03, 0x03, 0x04]); // ^C ^C ^D - tmp.write(seq, (werr) => { - if (werr) return reject(werr); - tmp.drain(() => tmp.close(() => resolve())); - }); - }); - }); - this.outputChannel.appendLine('Stopping code'); - } catch (e: any) { - this.outputChannel.appendLine(`⚠️ Could not send ^C/^D via serial: ${e?.message || e}`); - // Fallback: do a quick reset via mpremote, but with a hard timeout so we never hang - const execWithTimeout = (cmd: string, ms: number) => - new Promise((resolve, reject) => { - const child = exec(cmd, (err, _o, se) => { - if (err) { - if ((child as any).killed) return resolve(); - return reject(new Error(se || err.message)); - } - resolve(); - }); - const t = setTimeout(() => { try { child.kill(); } catch {} }, ms); - child.on('exit', () => clearTimeout(t)); - }); - - try { - await execWithTimeout(`mpremote connect ${port} soft-reset`, 1500); - this.outputChannel.appendLine('πŸ” Soft reset via mpremote.'); - } catch { - try { - await execWithTimeout(`mpremote connect ${port} exec "import machine; machine.reset()"`, 2500); - this.outputChannel.appendLine('πŸ” Hard reset via machine.reset().'); - } catch (err2: any) { - this.outputChannel.appendLine(`❌ Reset fallback failed: ${err2?.message || String(err2)}`); - } - } - } - - // 4) Optionally reopen the serial monitor so the user can see the fresh prompt - if (wantReopen) { - await new Promise(r => setTimeout(r, 250)); - this.startSerialMonitor(port); - this.outputChannel.appendLine('πŸ“‘ Serial monitor reopened.'); - } - - break; - } - - // ---- Ask for an updated list of ports ---- - case 'getPorts': { - const ports = await SerialPort.list(); - this._view?.webview.postMessage({ - command: 'populatePorts', - ports: ports.map(p => p.path), - }); - break; - } - - // ---- Start serial monitor manually ---- - case 'startSerialMonitor': { - const { port } = message; - if (!port) { - vscode.window.showErrorMessage('Please select a port.'); - return; - } - this.startSerialMonitor(port); - break; - } - - // ---- Upload one file or a folder of .py files from PC ---- - case 'uploadPythonFromPc': { - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.outputChannel.appendLine('πŸ›‘ Stopping serial monitor before proceeding...'); - this.serialMonitor.close(); - this.serialMonitor = null; - } - - const choice = await vscode.window.showQuickPick( - ['Single Python File', 'Folder of Python Files (including subfolders)'], - { placeHolder: 'Do you want to upload a single file or a folder?' } - ); - if (!choice) return; - - // Show proper picker based on choice - let selection: vscode.Uri[] | undefined; - if (choice === 'Single Python File') { - selection = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { 'Python Files': ['py'] }, - }); - } else { - selection = await vscode.window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - }); - } - if (!selection || selection.length === 0) { - vscode.window.showErrorMessage('No file or folder selected.'); - return; - } - - const selectedPath = selection[0].fsPath; - const stats = fs.lstatSync(selectedPath); - const uploadCommands: string[] = []; - - if (stats.isDirectory()) { - // Recursively gather all .py files (flattened) - const walk = (dir: string) => { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.isFile() && entry.name.endsWith('.py')) { - const fileName = path.basename(fullPath); - uploadCommands.push(`mpremote connect ${message.port} fs cp "${fullPath}" :"${fileName}"`); - } - } - }; - - walk(selectedPath); - - if (uploadCommands.length === 0) { - vscode.window.showErrorMessage('Selected folder does not contain any .py files.'); - return; - } - } else { - // Single .py file - const fileName = path.basename(selectedPath); - uploadCommands.push(`mpremote connect ${message.port} fs cp "${selectedPath}" :"${fileName}"`); - } - - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Uploading Python file(s)...', cancellable: false }, - async () => { - try { - for (const cmd of uploadCommands) { - await new Promise((resolve, reject) => { - exec(cmd, (err, _stdout, stderr) => { - if (err) { - vscode.window.showErrorMessage(`Upload failed: ${stderr || err.message}`); - reject(err); - } else { - resolve(); - } - }); - }); - } - - vscode.window.showInformationMessage('All .py files uploaded successfully!'); - this._view?.webview.postMessage({ command: 'triggerListFiles', port: message.port }); - } catch (err) { - this.outputChannel.appendLine(`❌ Upload error: ${err instanceof Error ? err.message : String(err)}`); - } - } - ); - break; - } - - // ---- Fetch Soldered module files (library/examples/both) ---- - case 'fetchModule': { - const { sensor, port, mode } = message; - - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.outputChannel.appendLine('πŸ›‘ Stopping serial monitor before fetching module...'); - this.serialMonitor.close(); - this.serialMonitor = null; - } - - if (!sensor || !port) { - vscode.window.showErrorMessage('Module name and port are required.'); - return; - } - - const categories = ['Sensors', 'Displays', 'Actuators']; - let baseUrl: string | undefined; - - // Try to detect the correct category by probing GitHub - for (const category of categories) { - const testUrl = `https://api.github.com/repos/SolderedElectronics/Soldered-MicroPython-Modules/contents/${category}/${sensor}/${sensor}`; - const res: any = await new Promise((resolve) => { - https.get(testUrl, { headers: { 'User-Agent': 'vscode-extension' } }, resolve) - .on('error', () => resolve(undefined)); - }); - if (res?.statusCode === 200) { - baseUrl = testUrl; - break; - } - } - - if (!baseUrl) { - vscode.window.showErrorMessage(`❌ Could not find module "${sensor}" in any known category.`); - return; - } - - const targets: string[] = []; - if (mode === 'library' || mode === 'all') targets.push(baseUrl); - if (mode === 'examples' || mode === 'all') targets.push(`${baseUrl}/Examples`); - - for (const url of targets) { - await new Promise((resolve) => { - https.get(url, { headers: { 'User-Agent': 'vscode-extension' } }, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', async () => { - try { - const files = JSON.parse(data); - const pyFiles = files.filter((f: any) => f.name.endsWith('.py')); - - if (pyFiles.length === 0) { - vscode.window.showWarningMessage(`No .py files found in ${url}`); - return resolve(); - } - - for (const file of pyFiles) { - const uploadName = file.name.replace(/-/g, '_'); // normalize filename for device - const tempPath = path.join(os.tmpdir(), uploadName); - await this.downloadFile(file.download_url, tempPath); - - const uploadCmd = `mpremote connect ${port} fs cp "${tempPath}" :"${uploadName}"`; - this.outputChannel.appendLine(`⬆ Uploading ${uploadName}`); - await execCommand(uploadCmd); - } - - resolve(); - } catch (err) { - vscode.window.showErrorMessage(`Failed to process ${url}`); - this.outputChannel.appendLine(`❌ Error: ${err}`); - this._view?.webview.postMessage({ command: 'moduleFetchStatus', mode, status: 'error' }); - resolve(); - } - }); - }).on('error', err => { - vscode.window.showErrorMessage(`Failed to fetch ${url}: ${err.message}`); - resolve(); - }); - }); - } - - vscode.window.showInformationMessage(`βœ… Downloaded ${mode} files for "${sensor}"`); - this._view?.webview.postMessage({ command: 'triggerListFiles', port }); - this._view?.webview.postMessage({ command: 'moduleFetchStatus', mode, status: 'done' }); - break; - } - - // ---- Search Soldered modules by keyword ---- - case 'searchModules': { - const keyword = message.keyword || ''; - const categories = ['Sensors', 'Displays', 'Actuators']; - const allModules: string[] = []; - - await Promise.all(categories.map(category => { - const apiUrl = `https://api.github.com/repos/SolderedElectronics/Soldered-MicroPython-Modules/contents/${category}`; - return new Promise((resolve) => { - https.get(apiUrl, { headers: { 'User-Agent': 'vscode-extension' } }, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const folders = JSON.parse(data) - .filter((f: any) => f.type === 'dir') - .map((f: any) => f.name); - allModules.push(...folders); - } catch (_err) { - vscode.window.showErrorMessage(`Failed to parse module list for ${category}.`); - } - resolve(); - }); - }).on('error', err => { - vscode.window.showErrorMessage(`GitHub API error for ${category}: ${err.message}`); - resolve(); - }); - }); - })); - - const fuse = new Fuse(allModules, { - threshold: 0.4, - ignoreLocation: true, - isCaseSensitive: false, - }); - - const matches = fuse.search(keyword).slice(0, 15).map(m => m.item); - this._view?.webview.postMessage({ command: 'setModuleMatches', matches }); - break; - } - - // ---- Delete a file on the device ---- - case 'deleteFile': { - if (this.serialMonitor && this.serialMonitor.isOpen) { - this.outputChannel.appendLine('πŸ›‘ Stopping serial monitor before deleting...'); - this.serialMonitor.close(); - this.serialMonitor = null; - } - - const delCmd = `mpremote connect ${port} exec "import os; os.remove('${message.filename}')"`; - exec(delCmd, (err, _stdout, stderr) => { - if (err) { - vscode.window.showErrorMessage(`Failed to delete file: ${stderr || err.message}`); - } else { - vscode.window.showInformationMessage(`Deleted ${message.filename} successfully.`); - this._view?.webview.postMessage({ command: 'triggerListFiles', port }); - } - }); - break; - } - - // Optional: allow a no-op to just set lastPort from the webview - case 'noop': { - // Intentionally empty - break; - } - - default: { - this.outputChannel.appendLine(`β„Ή Unknown command from webview: ${message.command}`); - break; - } - } - }); -} - -// Loads the HTML content for the Webview panel from an external file -private getHtml(): string { - const htmlPath = path.join(this.context.extensionPath, 'src', 'panel', 'index.html'); - let html = fs.readFileSync(htmlPath, 'utf8'); - - // Convert mp.png to a webview-safe URI - const mpIconUri = this._view?.webview.asWebviewUri( - vscode.Uri.joinPath(this.context.extensionUri, 'resources', 'mp.svg') - ); - - // Inject the URI into HTML placeholder - html = html.replace('{{mpIconUri}}', mpIconUri?.toString() || ''); - - return html; -} -} \ No newline at end of file diff --git a/src/handlers/fileHandler.ts b/src/handlers/fileHandler.ts new file mode 100644 index 0000000..3f4d64c --- /dev/null +++ b/src/handlers/fileHandler.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; +import { exec } from 'child_process'; +import { HandlerContext } from '../types'; + +/** + * Lists files on the connected MicroPython device and sends them to the webview. + */ +export function handleListFiles(ctx: HandlerContext, message: any): void { + const { port } = message; + // Use json.dumps so output is valid JSON β€” no quote-replacement hacks needed + const listCmd = `mpremote connect ${port} exec "import os, json; print(json.dumps(os.listdir()))"`; + + exec(listCmd, (err, stdout, stderr) => { + if (err) { + vscode.window.showErrorMessage(`Failed to list files: ${stderr || err.message}`); + return; + } + + try { + // Find the JSON array line β€” mpremote may print extra lines/warnings before it + const match = stdout.match(/\[.*\]/); + const files: string[] = match ? JSON.parse(match[0]) : []; + ctx.postMessage({ command: 'displayFiles', files }); + } catch (_e) { + vscode.window.showErrorMessage('Failed to parse file list.'); + } + }); +} + +/** + * Deletes a file from the connected MicroPython device. + */ +export function handleDeleteFile(ctx: HandlerContext, message: any): void { + const { port, filename } = message; + + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.outputChannel.appendLine('Stopping serial monitor before deleting...'); + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + const safeFilename = filename.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const delCmd = `mpremote connect ${port} exec "import os; os.remove('${safeFilename}')"`; + exec(delCmd, (err, _stdout, stderr) => { + if (err) { + vscode.window.showErrorMessage(`Failed to delete file: ${stderr || err.message}`); + } else { + vscode.window.showInformationMessage(`Deleted ${filename} successfully.`); + ctx.postMessage({ command: 'triggerListFiles', port }); + } + }); +} diff --git a/src/handlers/flashHandler.ts b/src/handlers/flashHandler.ts new file mode 100644 index 0000000..e58d835 --- /dev/null +++ b/src/handlers/flashHandler.ts @@ -0,0 +1,214 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as https from 'https'; +import * as cheerio from 'cheerio'; +import { exec } from 'child_process'; +import { HandlerContext } from '../types'; + +/** + * Downloads a file from HTTPS URL to a local destination path. + */ +export async function downloadFile(url: string, dest: string): Promise { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + + https.get(url, response => { + response.pipe(file); + + file.on('finish', () => { + file.close(err => { + if (err) reject(err); + else resolve(); + }); + }); + }).on('error', err => { + fs.unlink(dest, () => reject(err)); + }); + }); +} + +/** + * Fetches the latest MicroPython firmware list from micropython.org. + * Supports ESP32 (.bin), RP2040 (.uf2), and RP2350 (.uf2) boards. + */ +export async function fetchFirmwareList( + ctx: Pick +): Promise<{ name: string; url: string; boardType: string; version: string }[]> { + const baseUrl = 'https://micropython.org'; + + const esp32Slugs = ['ESP32_GENERIC', 'ESP32_GENERIC_C3', 'ESP32_GENERIC_C2', 'ESP32_GENERIC_C6', 'ESP32_GENERIC_S2', 'ESP32_GENERIC_S3']; + const rp2040Slugs = ['ARDUINO_NANO_RP2040_CONNECT', 'SPARKFUN_PROMICRO', 'RPI_PICO']; + const rp2350Slugs = ['RPI_PICO2', 'RPI_PICO2_W', 'SPARKFUN_PROMICRO_RP2350']; + + const allFirmwares: { name: string; url: string; boardType: string; version: string }[] = []; + + const fetchForSlug = (slug: string, extension: string, boardType: string) => { + return new Promise((resolve) => { + const fullUrl = `${baseUrl}/download/${slug}/`; + + https.get(fullUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const $ = cheerio.load(data); + let found = false; + + $('a').each((_, el) => { + if (found) return; + + const href = $(el).attr('href'); + if ( + href && + href.endsWith(extension) && + href.includes('/resources/firmware/') && + /v\d+\.\d+\.\d+/.test(href) && + !/(spiram|psram|ota|preview|test|IDF)/i.test(href) + ) { + const full = href.startsWith('http') ? href : `${baseUrl}${href}`; + const versionMatch = href.match(/v(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + allFirmwares.push({ name: path.basename(full), url: full, boardType, version }); + found = true; + } + }); + + resolve(); + }); + }).on('error', err => { + ctx.outputChannel.appendLine(`[WARN] Failed to fetch ${fullUrl}: ${err.message}`); + resolve(); + }); + }); + }; + + await Promise.all([ + ...esp32Slugs.map(slug => fetchForSlug(slug, '.bin', 'ESP32')), + ...rp2040Slugs.map(slug => fetchForSlug(slug, '.uf2', 'RP2040')), + ...rp2350Slugs.map(slug => fetchForSlug(slug, '.uf2', 'RP2350')), + ]); + + return allFirmwares; +} + +/** + * Handles firmware flashing triggered from the webview (web download). + * Supports UF2 (RP boards) and .bin (ESP32) formats. + */ +export async function handleFlashFromWeb(ctx: HandlerContext, firmwareUrl: string, port: string): Promise { + const firmwareName = path.basename(firmwareUrl); + const tmpPath = path.join(os.tmpdir(), firmwareName); + const isUF2 = firmwareUrl.endsWith('.uf2'); + + await downloadFile(firmwareUrl, tmpPath); + + if (isUF2) { + const selectedFolder = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + openLabel: 'Select RP drive (mass storage)', + }); + + if (!selectedFolder || selectedFolder.length === 0) { + vscode.window.showWarningMessage('Firmware flash cancelled (no folder selected).'); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'error' }); + return; + } + + const dest = path.join(selectedFolder[0].fsPath, firmwareName); + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Flashing firmware...', cancellable: false }, + () => new Promise((resolve, reject) => { + ctx.postMessage({ command: 'flashStatusUpdate', text: 'start' }); + ctx.outputChannel.appendLine(`Copying UF2 to: ${dest}`); + + try { + fs.copyFileSync(tmpPath, dest); + vscode.window.showInformationMessage('UF2 firmware copied successfully!'); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'done' }); + ctx.outputChannel.appendLine(`UF2 copied to: ${dest} - unplug and replug your board now`); + resolve(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to copy UF2 file: ${err.message}`); + ctx.outputChannel.appendLine(`[ERROR] Failed to copy UF2: ${err.message}`); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'error' }); + reject(err); + } + }) + ); + + } else { + const esptoolPath = vscode.workspace.getConfiguration('mp').get('esptoolPath', 'esptool'); + const command = `${esptoolPath} --port ${port} --baud 115200 write_flash --flash_mode keep --flash_size keep --erase-all 0x1000 "${tmpPath}"`; + ctx.outputChannel.appendLine(`Executing: ${command}`); + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Flashing firmware...', cancellable: false }, + () => new Promise((resolve, reject) => { + exec(command, (err, stdout, stderr) => { + ctx.outputChannel.appendLine('[stdout]'); + ctx.outputChannel.appendLine(stdout); + if (stderr) { + ctx.outputChannel.appendLine('[stderr]'); + ctx.outputChannel.appendLine(stderr); + } + + if (err) { + vscode.window.showErrorMessage(`Flash failed: ${stderr || err.message}`); + reject(err); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'error' }); + } else { + vscode.window.showInformationMessage('Flash successful!'); + resolve(); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'done' }); + ctx.postMessage({ command: 'triggerListFiles', port }); + } + }); + }) + ); + } +} + +/** + * Handles flashing a locally selected .bin firmware file. + */ +export async function handleFlashFirmware(ctx: HandlerContext, message: any): Promise { + const fileUri = await vscode.window.showOpenDialog({ + filters: { 'BIN files': ['bin'] }, + canSelectMany: false, + }); + if (!fileUri) { + vscode.window.showErrorMessage('No firmware file selected.'); + return; + } + + const firmwarePath = fileUri[0].fsPath; + const esptoolPath = vscode.workspace.getConfiguration('mp').get('esptoolPath', 'esptool'); + const cmd = `${esptoolPath} --port ${message.port} --baud 115200 write_flash --flash_mode keep --flash_size keep --erase-all 0x1000 "${firmwarePath}"`; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Flashing firmware...', cancellable: false }, + () => new Promise((resolve, reject) => { + ctx.postMessage({ command: 'flashStatusUpdate', text: 'start' }); + + exec(cmd, (error, stdout, stderr) => { + console.log('Command:', cmd); + console.log('STDOUT:', stdout); + console.log('STDERR:', stderr); + + if (error) { + vscode.window.showErrorMessage(`Firmware flashing failed: ${stderr || error.message}`); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'error' }); + reject(error); + } else { + vscode.window.showInformationMessage('Firmware flashed successfully!'); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'done' }); + resolve(); + } + }); + }) + ); +} diff --git a/src/handlers/moduleHandler.ts b/src/handlers/moduleHandler.ts new file mode 100644 index 0000000..41f760c --- /dev/null +++ b/src/handlers/moduleHandler.ts @@ -0,0 +1,164 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; +import * as https from 'https'; +import { HandlerContext } from '../types'; +import { execCommand } from '../utils/execUtils'; +import { downloadFile } from './flashHandler'; + +const REPO_ROOT = 'https://api.github.com/repos/SolderedElectronics/Soldered-MicroPython-Modules/contents'; +const FALLBACK_CATEGORIES = ['Sensors', 'Displays', 'Actuators']; +const IGNORE_FOLDERS = ['.github', 'moduletemplate', 'img']; + +/** + * Fetches all top-level directory names from the repo root. + * Falls back to hardcoded list if the request fails. + */ +async function fetchCategories(): Promise { + return new Promise((resolve) => { + https.get(REPO_ROOT, { headers: { 'User-Agent': 'vscode-extension' } }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const items = JSON.parse(data); + const folders = items + .filter((f: any) => f.type === 'dir' && !IGNORE_FOLDERS.includes(f.name.toLowerCase())) + .map((f: any) => f.name); + resolve(folders.length ? folders : FALLBACK_CATEGORIES); + } catch { + resolve(FALLBACK_CATEGORIES); + } + }); + }).on('error', () => resolve(FALLBACK_CATEGORIES)); + }); +} + +/** + * Returns all top-level category folders from the repo to the webview. + */ +export async function handleGetCategories(ctx: HandlerContext): Promise { + const categories = await fetchCategories(); + ctx.postMessage({ command: 'setCategories', categories }); +} + +/** + * Returns all module folders inside a given category. + */ +export async function handleGetModulesForCategory(ctx: HandlerContext, message: any): Promise { + const { category } = message; + if (!category) { + ctx.postMessage({ command: 'setModulesForCategory', modules: [] }); + return; + } + + const apiUrl = `${REPO_ROOT}/${category}`; + return new Promise((resolve) => { + https.get(apiUrl, { headers: { 'User-Agent': 'vscode-extension' } }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const modules = JSON.parse(data) + .filter((f: any) => f.type === 'dir') + .map((f: any) => f.name); + ctx.postMessage({ command: 'setModulesForCategory', modules }); + } catch { + ctx.outputChannel.appendLine(`[WARN] Failed to fetch modules for ${category}`); + ctx.postMessage({ command: 'setModulesForCategory', modules: [] }); + } + resolve(); + }); + }).on('error', err => { + ctx.outputChannel.appendLine(`[WARN] GitHub API error for ${category}: ${err.message}`); + ctx.postMessage({ command: 'setModulesForCategory', modules: [] }); + resolve(); + }); + }); +} + +/** + * Fetches a Soldered module (library/examples/both) from GitHub and uploads to device. + */ +export async function handleFetchModule(ctx: HandlerContext, message: any): Promise { + const { sensor, port, mode } = message; + + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.outputChannel.appendLine('Stopping serial monitor before fetching module...'); + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + if (!sensor || !port) { + vscode.window.showErrorMessage('Module name and port are required.'); + return; + } + + const categories = await fetchCategories(); + let baseUrl: string | undefined; + + for (const category of categories) { + const testUrl = `${REPO_ROOT}/${category}/${sensor}/${sensor}`; + const res: any = await new Promise((resolve) => { + https.get(testUrl, { headers: { 'User-Agent': 'vscode-extension' } }, resolve) + .on('error', () => resolve(undefined)); + }); + if (res?.statusCode === 200) { + baseUrl = testUrl; + break; + } + } + + if (!baseUrl) { + vscode.window.showErrorMessage(`Could not find module "${sensor}" in any category.`); + return; + } + + const targets: string[] = []; + if (mode === 'library' || mode === 'all') targets.push(baseUrl); + if (mode === 'examples' || mode === 'all') targets.push(`${baseUrl}/Examples`); + + for (const url of targets) { + await new Promise((resolve) => { + https.get(url, { headers: { 'User-Agent': 'vscode-extension' } }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', async () => { + try { + const files = JSON.parse(data); + const pyFiles = files.filter((f: any) => f.name.endsWith('.py')); + + if (pyFiles.length === 0) { + vscode.window.showWarningMessage(`No .py files found in ${url}`); + return resolve(); + } + + for (const file of pyFiles) { + const uploadName = file.name.replace(/-/g, '_'); + const tempPath = path.join(os.tmpdir(), uploadName); + await downloadFile(file.download_url, tempPath); + + const uploadCmd = `mpremote connect ${port} fs cp "${tempPath}" :"${uploadName}"`; + ctx.outputChannel.appendLine(`Uploading ${uploadName}`); + await execCommand(uploadCmd, ctx.outputChannel); + } + + resolve(); + } catch (err) { + vscode.window.showErrorMessage(`Failed to process ${url}`); + ctx.outputChannel.appendLine(`[ERROR] ${err}`); + ctx.postMessage({ command: 'moduleFetchStatus', mode, status: 'error' }); + resolve(); + } + }); + }).on('error', err => { + vscode.window.showErrorMessage(`Failed to fetch ${url}: ${err.message}`); + resolve(); + }); + }); + } + + vscode.window.showInformationMessage(`Downloaded ${mode} files for "${sensor}"`); + ctx.postMessage({ command: 'triggerListFiles', port }); + ctx.postMessage({ command: 'moduleFetchStatus', mode, status: 'done' }); +} diff --git a/src/handlers/serialHandler.ts b/src/handlers/serialHandler.ts new file mode 100644 index 0000000..e61e737 --- /dev/null +++ b/src/handlers/serialHandler.ts @@ -0,0 +1,185 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; +import { exec, spawn } from 'child_process'; +import { SerialPort } from 'serialport'; +import { HandlerContext } from '../types'; +import { execCommand, execWithTimeout, killProc } from '../utils/execUtils'; + +/** + * Starts the serial monitor on the given port. + * Closes any existing monitor first. + */ +export function startSerialMonitor(ctx: HandlerContext, portPath: string): void { + if (ctx.serialMonitor) { + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + ctx.outputChannel.appendLine(`Opening serial monitor on ${portPath}`); + + const monitor = new SerialPort({ + path: portPath, + baudRate: 115200, + autoOpen: true, + }); + + monitor.on('data', (data: Buffer) => { + ctx.outputChannel.append(data.toString('utf-8')); + }); + + monitor.on('error', err => { + ctx.outputChannel.appendLine(`Serial error: ${err.message}`); + }); + + monitor.on('close', () => { + ctx.outputChannel.appendLine('Serial monitor closed.'); + }); + + ctx.setSerialMonitor(monitor); +} + +/** + * Stops the serial monitor and sends a soft-reset to the device. + */ +export function stopSerialMonitorAndReset(ctx: HandlerContext, portPath: string): void { + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.serialMonitor.close(err => { + if (err) ctx.outputChannel.appendLine(`[ERROR] Error closing serial monitor: ${err.message}`); + }); + ctx.setSerialMonitor(null); + } + + const resetCmd = `mpremote connect ${portPath} soft-reset`; + exec(resetCmd, (err, _stdout, stderr) => { + if (err) { + ctx.outputChannel.appendLine(`[ERROR] Error resetting device: ${stderr || err.message}`); + } else { + ctx.outputChannel.appendLine('Device reset successfully.'); + } + }); +} + +/** + * Downloads a device file to temp and runs it via mpremote, streaming output live. + */ +export async function handleRunPythonFile(ctx: HandlerContext, message: any): Promise { + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + if (ctx.mpRunProc) { + try { ctx.mpRunProc.kill(); } catch {} + ctx.setMpRunProc(null); + } + + const { filename, port } = message; + if (!filename || !port) { + vscode.window.showErrorMessage('Filename and port are required to run the script.'); + ctx.outputChannel.appendLine('[WARN] Cannot run script: filename or port not provided.'); + return; + } + + const tempPath = path.join(os.tmpdir(), `__run_tmp__-${path.basename(filename)}`); + const downloadCmd = `mpremote connect ${port} fs cp :${filename} "${tempPath}"`; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Preparing ${filename}...`, cancellable: false }, + async () => { + await execCommand(downloadCmd, ctx.outputChannel); + } + ); + + ctx.outputChannel.appendLine(`Running ${filename}`); + ctx.outputChannel.show(true); + + const args = ['connect', port, 'run', tempPath]; + const child = spawn('mpremote', args, { shell: false }); + + ctx.setMpRunProc(child); + + child.stdout.on('data', (data: Buffer) => { + ctx.outputChannel.append(data.toString('utf-8')); + }); + child.stderr.on('data', (data: Buffer) => { + ctx.outputChannel.append(data.toString('utf-8')); + }); + child.on('close', (code) => { + ctx.outputChannel.appendLine(`\n[run exited with code ${code ?? 0}]`); + ctx.setMpRunProc(null); + }); + child.on('error', (err) => { + vscode.window.showErrorMessage(`Failed to start mpremote run: ${err.message}`); + ctx.outputChannel.appendLine(`[ERROR] Failed to start mpremote run: ${err.message}`); + ctx.setMpRunProc(null); + }); + + vscode.window.showInformationMessage(`${filename} is running. Use "Stop" to interrupt.`); +} + +/** + * Stops any running mpremote process and sends Ctrl-C / Ctrl-D to the board. + * Falls back to mpremote soft-reset / machine.reset() if serial write fails. + */ +export async function handleStopRunningCode(ctx: HandlerContext, message: any): Promise { + const { port, keepMonitor } = message as { port?: string; keepMonitor?: boolean }; + if (!port) { + vscode.window.showErrorMessage('Port is required to stop running code.'); + return; + } + + // 1) Kill active mpremote run process + if (ctx.mpRunProc) { + await killProc(ctx.mpRunProc); + ctx.setMpRunProc(null); + } + + // 2) Close serial monitor to free the port + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + // Short grace so OS releases the port handle + await new Promise(r => setTimeout(r, 150)); + + // 3) Send Ctrl-C, Ctrl-C, Ctrl-D directly over serial + try { + await new Promise((resolve, reject) => { + const tmp = new SerialPort({ path: port, baudRate: 115200, autoOpen: false }); + tmp.open(err => { + if (err) return reject(err); + const seq = Buffer.from([0x03, 0x03, 0x04]); // ^C ^C ^D + tmp.write(seq, (werr) => { + if (werr) return reject(werr); + tmp.drain(() => tmp.close(() => resolve())); + }); + }); + }); + ctx.outputChannel.appendLine('Stopping code'); + } catch (e: any) { + ctx.outputChannel.appendLine(`[WARN] Could not send ^C/^D via serial: ${e?.message || e}`); + + // Fallback: mpremote soft-reset with timeout + try { + await execWithTimeout(`mpremote connect ${port} soft-reset`, 1500); + ctx.outputChannel.appendLine('Soft reset via mpremote.'); + } catch { + try { + await execWithTimeout(`mpremote connect ${port} exec "import machine; machine.reset()"`, 2500); + ctx.outputChannel.appendLine('Hard reset via machine.reset().'); + } catch (err2: any) { + ctx.outputChannel.appendLine(`[ERROR] Reset fallback failed: ${err2?.message || String(err2)}`); + } + } + } + + // 4) Optionally reopen the serial monitor + if (keepMonitor) { + await new Promise(r => setTimeout(r, 250)); + startSerialMonitor(ctx, port); + ctx.outputChannel.appendLine('Serial monitor reopened.'); + } +} diff --git a/src/handlers/uploadHandler.ts b/src/handlers/uploadHandler.ts new file mode 100644 index 0000000..6f55c59 --- /dev/null +++ b/src/handlers/uploadHandler.ts @@ -0,0 +1,202 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { exec } from 'child_process'; +import { HandlerContext } from '../types'; + +/** + * Uploads the active editor's Python file to the device as main.py. + */ +export async function handleUploadPython(ctx: HandlerContext, message: any): Promise { + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor || activeEditor.document.languageId !== 'python') { + vscode.window.showErrorMessage('No active Python file to upload.'); + return; + } + + const filePath = activeEditor.document.fileName; + const { port } = message; + const uploadCmd = `mpremote connect ${port} fs cp "${filePath}" :main.py`; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Uploading Python file as main.py...', cancellable: false }, + () => new Promise((resolve, reject) => { + exec(uploadCmd, (uploadError, _stdout, uploadStderr) => { + if (uploadError) { + vscode.window.showErrorMessage(`Upload failed: ${uploadStderr || uploadError.message}`); + reject(uploadError); + return; + } + vscode.window.showInformationMessage('Python file uploaded successfully as main.py!'); + ctx.postMessage({ command: 'triggerListFiles', port }); + resolve(); + }); + }) + ); +} + +/** + * Uploads the active editor's Python file to the device preserving its original filename. + */ +export async function handleUploadPythonAsIs(ctx: HandlerContext, message: any): Promise { + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor || activeEditor.document.languageId !== 'python') { + vscode.window.showErrorMessage('No active Python file to upload.'); + return; + } + + const filePath = activeEditor.document.fileName; + const fileName = filePath.split(/[/\\]/).pop()!; + const { port } = message; + const uploadCmd = `mpremote connect ${port} fs cp "${filePath}" :"${fileName}"`; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Uploading ${fileName} to device...`, cancellable: false }, + () => new Promise((resolve, reject) => { + exec(uploadCmd, (uploadError, _stdout, uploadStderr) => { + if (uploadError) { + vscode.window.showErrorMessage(`Upload failed: ${uploadStderr || uploadError.message}`); + reject(uploadError); + return; + } + vscode.window.showInformationMessage(`${fileName} uploaded successfully!`); + ctx.postMessage({ command: 'triggerListFiles', port }); + resolve(); + }); + }) + ); +} + +/** + * Prompts user to pick a single .py file or folder, then uploads to device. + */ +export async function handleUploadPythonFromPc(ctx: HandlerContext, message: any): Promise { + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + + const choice = await vscode.window.showQuickPick( + ['Single Python File', 'Folder of Python Files (including subfolders)'], + { placeHolder: 'Do you want to upload a single file or a folder?' } + ); + if (!choice) return; + + let selection: vscode.Uri[] | undefined; + if (choice === 'Single Python File') { + selection = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { 'Python Files': ['py'] }, + }); + } else { + selection = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }); + } + + if (!selection || selection.length === 0) { + vscode.window.showErrorMessage('No file or folder selected.'); + return; + } + + const selectedPath = selection[0].fsPath; + const stats = fs.lstatSync(selectedPath); + const uploadCommands: string[] = []; + const { port } = message; + + if (stats.isDirectory()) { + const walk = (dir: string) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.py')) { + const fileName = path.basename(fullPath); + uploadCommands.push(`mpremote connect ${port} fs cp "${fullPath}" :"${fileName}"`); + } + } + }; + + walk(selectedPath); + + if (uploadCommands.length === 0) { + vscode.window.showErrorMessage('Selected folder does not contain any .py files.'); + return; + } + } else { + const fileName = path.basename(selectedPath); + uploadCommands.push(`mpremote connect ${port} fs cp "${selectedPath}" :"${fileName}"`); + } + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Uploading Python file(s)...', cancellable: false }, + async () => { + try { + for (const cmd of uploadCommands) { + await new Promise((resolve, reject) => { + exec(cmd, (err, _stdout, stderr) => { + if (err) { + vscode.window.showErrorMessage(`Upload failed: ${stderr || err.message}`); + reject(err); + } else { + resolve(); + } + }); + }); + } + vscode.window.showInformationMessage('All .py files uploaded successfully!'); + ctx.postMessage({ command: 'triggerListFiles', port }); + } catch (err) { + ctx.outputChannel.appendLine(`[ERROR] Upload error: ${err instanceof Error ? err.message : String(err)}`); + } + } + ); +} + +/** + * Downloads a file from the device to a temp location and opens it in the editor. + */ +export async function handleOpenFileFromDevice(ctx: HandlerContext, message: any): Promise { + const { port, filename } = message; + const tempDir = path.join(os.tmpdir(), 'esp-temp'); + const localPath = path.join(tempDir, filename); + + try { + await fs.promises.mkdir(tempDir, { recursive: true }); + const cmd = `mpremote connect ${port} fs cp :"${filename}" "${localPath}"`; + + ctx.outputChannel.appendLine(`Downloading ${filename} from device...`); + exec(cmd, async (err, _stdout, stderr) => { + if (err) { + vscode.window.showErrorMessage(`Failed to download file: ${stderr || err.message}`); + return; + } + + const doc = await vscode.workspace.openTextDocument(localPath); + await vscode.window.showTextDocument(doc, { preview: false }); + vscode.window.showInformationMessage(`Opened ${filename} from device.`); + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to prepare file for editing: ${msg}`); + } +} diff --git a/src/panel/index.html b/src/panel/index.html index 9479252..7ca972b 100644 --- a/src/panel/index.html +++ b/src/panel/index.html @@ -42,31 +42,50 @@ button { display: block; width: 100%; - padding: 10px 0; + padding: 10px 8px; margin-top: 10px; margin-bottom: 10px; + min-height: 52px; background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); font-weight: bold; border: none; border-radius: 6px; cursor: pointer; + text-align: center; + line-height: 1.4; } button:hover { background-color: var(--vscode-button-hoverBackground); } + button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .btn-danger { + background-color: var(--vscode-statusBarItem-errorBackground); + color: var(--vscode-statusBarItem-errorForeground); + } + + .btn-danger:hover { + filter: brightness(1.15); + } + .buttons-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; + align-items: stretch; } .buttons-row button { flex: 1; margin-top: 0; + margin-bottom: 0; } .section-content { @@ -115,61 +134,6 @@ color: var(--vscode-foreground); } - .icon-button-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin: 12px 0; - } - - .icon-button { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - border-radius: 6px; - padding: 8px; - width: 100%; - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.2s ease; - cursor: pointer; - } - - .icon-button:hover { - background-color: var(--vscode-button-hoverBackground); - } - - .icon-button svg { - width: 18px; - height: 18px; - stroke: currentColor; - } - - .icon-button:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 2px; - } - - .icon-button-row { - margin-left: 6px; - display: flex; - flex-wrap: wrap; - gap: 6px; /* tighter spacing */ - margin-bottom: 5px; - } - - .icon-button-row .icon-button { - width: 44px; - height: 44px; - flex: 0 0 auto; /* prevent stretching */ - display: flex; - align-items: center; - justify-content: center; - padding: 6px; - border-radius: 6px; - } #fileSelect { margin-top: 10px; @@ -259,14 +223,13 @@

Soldered MicroPython Helper

- -
+ +

β–Ά Info & Instructions

-

Soldered MicroPython Helper is a Visual Studio Code extension that helps you get started with your MicroPython projects using a friendly graphical interface.

@@ -277,57 +240,49 @@

You can explore the full source code on GitHub, submit issues or pull requests, and leave feedback on the extension page in the VS Code Marketplace.

-
    -
  • Flash Firmware: The Download + Flash from Web option supports ESP32_GENERIC (via esptool, .bin) and RP2040/RP2350 boards (via .uf2 copy to the boot drive; e.g., Raspberry Pi Pico / Pico W, Pico 2 / Pico 2 W, Arduino Nano RP2040 Connect, SparkFun Pro Micro RP2040/RP2350). Flashing methods may differ for other microcontrollersβ€”consult official docs if you're using a different board.
  • -
  • Upload Python Scripts: You can upload the currently active file as main.py, keep the original filename, or select a local .py file from your computer.
  • -
  • Manage Files: List, delete, run, or open files stored on the device. Double-click a file to open it in the VS Code editor.
  • -
  • Serial Monitor: View live output from the board over serial. Starts automatically after running a script, or can be launched manually.
  • -
  • Fetch Soldered Modules: Search and download libraries and/or examples for Soldered hardware modules. You don’t need to specify the category (e.g., Sensor, Display, Actuator).
  • +
  • Flash Firmware: The Download + Flash from Web option supports ESP32_GENERIC (via esptool, .bin) and RP2040/RP2350 boards (via .uf2 copy to the boot drive).
  • +
  • Upload Python Scripts: Upload the currently open file or pick a file from your PC.
  • +
  • Manage Files: List, delete, run, or open files stored on the device. Double-click a file to open it in the editor.
  • +
  • Serial Monitor: View live output from the board over serial.
  • +
  • Fetch Soldered Modules: Search and download libraries and examples for Soldered hardware modules.

- ⚠ If you encounter errors, make sure the selected port is correct and not in use by another application. + If you encounter errors, make sure the selected port is correct and not in use by another application.

- - + + -

- Only ESP32, RP2040 and RP2350 boards are supported for now. + Supported: ESP32, RP2040 and RP2350 boards.

- -
- +

- β–Ά Install Micropython on your board + β–Ά Install MicroPython on your board

-
+ + - - - - - - - - - - -

-

β–Ά Upload & Manage Python Scripts @@ -365,88 +317,40 @@

- -
- - - - - - - + +
+ +
- -
- - - + +
+ + +
- + + - + +
+ +
+ + + - - + +

No files found β€” click List Files.

+
@@ -457,14 +361,17 @@

β–Ά Fetch Soldered MicroPython Module

- - + + - - + +
- - - -
+ +
@@ -523,11 +429,9 @@

// === Init events === window.addEventListener('load', () => { - // When the webview is first loaded, fetch firmware options based on current input restoreToggleState(); - const board = document.getElementById('firmwareQuery').value; - const port = document.getElementById('port').value; - vscode.postMessage({ command: 'getFirmwareOptions', board, port }); + vscode.postMessage({ command: 'getFirmwareOptions' }); + vscode.postMessage({ command: 'getCategories' }); }); window.addEventListener('focus', () => { @@ -553,12 +457,6 @@

}); -// Update firmware options as the user types in the search input -document.getElementById('firmwareQuery').addEventListener('input', (e) => { - const board = e.target.value; - const port = document.getElementById('port').value; - vscode.postMessage({ command: 'getFirmwareOptions', board, port }); -}); // === Flash from Web === document.getElementById('flashFromWebBtn').addEventListener('click', () => { @@ -584,18 +482,42 @@

window.addEventListener('message', (event) => { const message = event.data; - // Populate firmware select box with search results + // Populate firmware select box grouped by board type if (message.command === 'setFirmwareOptions') { const select = document.getElementById('firmwareSelect'); - select.innerHTML = ''; // Clear previous options + select.innerHTML = ''; + + if (!message.options || message.options.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.disabled = true; + opt.textContent = 'No firmware found'; + select.appendChild(opt); + return; + } - // Create an

- +
@@ -410,22 +477,29 @@

- +
- +
- - -

No files found β€” click List Files.

- + +
+

No files found β€” click List Files.

+
+

@@ -488,6 +562,161 @@

// Gain access to the VS Code Webview API const vscode = acquireVsCodeApi(); +// Currently selected file path in the tree (null if nothing selected) +let selectedFilePath = null; +let selectedFileType = null; + +/** + * Renders a tree of file/dir nodes into the given container element. + * Folders are expanded by default and can be toggled. + * Files show Open / Run / Del inline buttons on hover. + */ +function renderFileTree(nodes, container, depth) { + const getPort = () => document.getElementById('port').value; + + nodes.forEach(node => { + const row = document.createElement('div'); + row.className = 'tree-row'; + row.dataset.path = node.path; + row.dataset.type = node.type; + + if (depth > 0) { + const indent = document.createElement('span'); + indent.style.cssText = `display:inline-block;width:${depth * 16}px;flex-shrink:0`; + row.appendChild(indent); + } + + if (node.type === 'dir') { + const toggle = document.createElement('span'); + toggle.className = 'tree-toggle'; + toggle.textContent = 'β–Ύ'; + row.appendChild(toggle); + + const label = document.createElement('span'); + label.className = 'tree-name'; + label.textContent = 'πŸ“ ' + node.name; + row.appendChild(label); + + const actions = document.createElement('span'); + actions.className = 'tree-actions'; + const delBtn = document.createElement('button'); + delBtn.className = 'tree-act-btn btn-danger'; + delBtn.textContent = 'Del'; + delBtn.title = 'Delete folder recursively'; + delBtn.addEventListener('click', e => { + e.stopPropagation(); + const port = getPort(); + if (!port) return; + vscode.postMessage({ command: 'deleteFile', port, filename: node.path, type: 'dir' }); + }); + actions.appendChild(delBtn); + row.appendChild(actions); + container.appendChild(row); + + const childrenDiv = document.createElement('div'); + childrenDiv.className = 'tree-children'; + container.appendChild(childrenDiv); + + if (node.children && node.children.length > 0) { + renderFileTree(node.children, childrenDiv, depth + 1); + } + + row.addEventListener('click', e => { + if (e.target === delBtn) return; + const collapsed = childrenDiv.classList.toggle('collapsed'); + toggle.textContent = collapsed ? 'β–Ά' : 'β–Ύ'; + }); + + } else { + const toggleSpacer = document.createElement('span'); + toggleSpacer.style.cssText = 'display:inline-block;width:14px;flex-shrink:0'; + row.appendChild(toggleSpacer); + + const label = document.createElement('span'); + label.className = 'tree-name'; + label.textContent = 'πŸ“„ ' + node.name; + row.appendChild(label); + + const actions = document.createElement('span'); + actions.className = 'tree-actions'; + + const openBtn = document.createElement('button'); + openBtn.className = 'tree-act-btn'; + openBtn.textContent = 'Open'; + openBtn.title = 'Open in editor'; + openBtn.addEventListener('click', e => { + e.stopPropagation(); + const port = getPort(); + if (!port) return; + vscode.postMessage({ command: 'openFileFromDevice', port, filename: node.path }); + }); + + const runBtn = document.createElement('button'); + runBtn.className = 'tree-act-btn'; + runBtn.textContent = 'Run'; + runBtn.title = 'Run on device'; + runBtn.addEventListener('click', e => { + e.stopPropagation(); + const port = getPort(); + if (!port) return; + vscode.postMessage({ command: 'runPythonFile', port, filename: node.path }); + }); + + const delBtn = document.createElement('button'); + delBtn.className = 'tree-act-btn btn-danger'; + delBtn.textContent = 'Del'; + delBtn.title = 'Delete file'; + delBtn.addEventListener('click', e => { + e.stopPropagation(); + const port = getPort(); + if (!port) return; + vscode.postMessage({ command: 'deleteFile', port, filename: node.path, type: 'file' }); + }); + + actions.appendChild(openBtn); + actions.appendChild(runBtn); + actions.appendChild(delBtn); + row.appendChild(actions); + + row.addEventListener('click', () => { + document.querySelectorAll('#fileTree .tree-row.selected').forEach(el => el.classList.remove('selected')); + row.classList.add('selected'); + selectedFilePath = node.path; + selectedFileType = 'file'; + updateButtonStates(getPort()); + }); + + row.addEventListener('dblclick', () => { + const port = getPort(); + if (!port) return; + vscode.postMessage({ command: 'openFileFromDevice', port, filename: node.path }); + }); + + container.appendChild(row); + } + }); +} + +/** + * Renders tree nodes into #fileTree, showing or hiding the empty message. + * Clears current selection. + */ +function renderFileTreeUI(nodes) { + const treeDiv = document.getElementById('fileTree'); + const emptyMsg = document.getElementById('fileListEmpty'); + treeDiv.innerHTML = ''; + selectedFilePath = null; + selectedFileType = null; + + if (!nodes || nodes.length === 0) { + emptyMsg.style.display = 'block'; + } else { + emptyMsg.style.display = 'none'; + renderFileTree(nodes, treeDiv, 0); + } + updateButtonStates(document.getElementById('port').value); +} + function formatCacheAge(timestamp) { const diffMs = Date.now() - timestamp; const diffMin = Math.floor(diffMs / 60000); @@ -553,10 +782,16 @@

}); // Serial Monitor Button +let serialMonitorActive = false; + document.getElementById('serialMonitorBtn').addEventListener('click', () => { const port = document.getElementById('port').value; if (!port) return; - vscode.postMessage({ command: 'startSerialMonitor', port }); + if (serialMonitorActive) { + vscode.postMessage({ command: 'stopSerialMonitor', port }); + } else { + vscode.postMessage({ command: 'startSerialMonitor', port }); + } }); document.getElementById('stopCodeBtn').addEventListener('click', () => { @@ -671,7 +906,6 @@

if (toSelect) { select.value = toSelect; vscode.postMessage({ command: 'noop', port: toSelect }); - vscode.postMessage({ command: 'listFiles', port: toSelect }); vscode.postMessage({ command: 'checkMicroPython', port: toSelect }); stopPortScanning(); } else { @@ -695,30 +929,11 @@

} } - // Show a list of files currently on the device + // Show files on the device as a tree if (message.command === 'displayFiles') { - const fileSelect = document.getElementById('fileSelect'); - const emptyMsg = document.getElementById('fileListEmpty'); - fileSelect.innerHTML = ''; - - // Save file list into local state currentState.files = message.files; - - if (message.files.length === 0) { - emptyMsg.style.display = 'block'; - fileSelect.style.display = 'none'; - } else { - emptyMsg.style.display = 'none'; - fileSelect.style.display = ''; - message.files.forEach(file => { - const option = document.createElement('option'); - option.value = file; - option.textContent = file; - fileSelect.appendChild(option); - }); - } - - saveState(); // Persist to VS Code state + renderFileTreeUI(message.files); + saveState(); } if (message.command === 'flashStatusUpdate') { @@ -726,6 +941,12 @@

setFlashingState(state); } + if (message.command === 'serialMonitorStatus') { + serialMonitorActive = message.active; + const btn = document.getElementById('serialMonitorBtn'); + if (btn) btn.textContent = message.active ? 'Stop Serial Monitor' : 'Serial Monitor'; + } + if (message.command === 'micropythonStatus') { if (message.installed) { const banner = document.getElementById('mpInstalledBanner'); @@ -776,14 +997,6 @@

}); -// Delete the selected file from the device -document.getElementById('deleteFileBtn').addEventListener('click', () => { - const port = document.getElementById('port').value; - const filename = document.getElementById('fileSelect').value; - if (!port || !filename) return; - vscode.postMessage({ command: 'deleteFile', port, filename }); -}); - // Delete all files from the device (keeps boot.py and main.py) document.getElementById('deleteAllFilesBtn').addEventListener('click', () => { const port = document.getElementById('port').value; @@ -798,30 +1011,25 @@

vscode.postMessage({ command: 'getAllModules', force: true }); }); -// Run the selected Python file from the device +// Run the selected Python file from the device (uses tree selection) document.getElementById('runFileBtn').addEventListener('click', () => { const port = document.getElementById('port').value; - const filename = document.getElementById('fileSelect').value; - if (!port || !filename) return; - vscode.postMessage({ command: 'runPythonFile', port, filename }); + if (!port || !selectedFilePath) return; + vscode.postMessage({ command: 'runPythonFile', port, filename: selectedFilePath }); }); -// Open File from Board button +// Open selected file from device in editor document.getElementById('openFileBtn').addEventListener('click', () => { const port = document.getElementById('port').value; - const filename = document.getElementById('fileSelect').value; - if (!port || !filename) return; - vscode.postMessage({ command: 'openFileFromDevice', port, filename }); + if (!port || !selectedFilePath) return; + vscode.postMessage({ command: 'openFileFromDevice', port, filename: selectedFilePath }); }); -// Double-click on a file to open it in VS Code editor -document.getElementById('fileSelect').addEventListener('dblclick', () => { +// Delete selected file or folder +document.getElementById('deleteFileBtn').addEventListener('click', () => { const port = document.getElementById('port').value; - const filename = document.getElementById('fileSelect').value; - if (!port || !filename) return; - - // Ask extension to download the file to temp and open it - vscode.postMessage({ command: 'openFileFromDevice', port, filename }); + if (!port || !selectedFilePath) return; + vscode.postMessage({ command: 'deleteFile', port, filename: selectedFilePath, type: selectedFileType || 'file' }); }); // Upload a .py file from user's computer to the device @@ -863,15 +1071,10 @@

}); } - // Restore file list - const fileSelect = document.getElementById('fileSelect'); - fileSelect.innerHTML = ''; - currentState.files.forEach(file => { - const option = document.createElement('option'); - option.value = file; - option.textContent = file; - fileSelect.appendChild(option); - }); + // Restore file tree + if (currentState.files && currentState.files.length > 0) { + renderFileTreeUI(currentState.files); + } } // Always ask backend to refresh available ports just in case @@ -895,7 +1098,7 @@

// IDs of dropdowns/inputs that require a port const PORT_DEPENDENT_INPUTS = [ - 'firmwareSelect', 'categorySelect', 'moduleSelect', 'fileSelect' + 'firmwareSelect', 'categorySelect', 'moduleSelect' ]; function updateButtonStates(port) { @@ -911,6 +1114,13 @@

const el = document.getElementById(id); if (el) el.disabled = noPort; }); + // Run / Open / Delete Selected also require a file to be selected in the tree + const runBtn = document.getElementById('runFileBtn'); + if (runBtn) runBtn.disabled = noPort || !selectedFilePath; + const openBtn = document.getElementById('openFileBtn'); + if (openBtn) openBtn.disabled = noPort || !selectedFilePath; + const delBtn = document.getElementById('deleteFileBtn'); + if (delBtn) delBtn.disabled = noPort || !selectedFilePath; // moduleSearchInput only enabled if port selected AND modules loaded const searchInput = document.getElementById('moduleSearchInput'); if (searchInput) searchInput.disabled = noPort || !allModulesLoaded; diff --git a/src/utils/execUtils.ts b/src/utils/execUtils.ts index 0cf7e2b..6fffc0f 100644 --- a/src/utils/execUtils.ts +++ b/src/utils/execUtils.ts @@ -78,17 +78,19 @@ export function execCommand(command: string, outputChannel?: OutputChannel): Pro const child = spawn('sh', ['-c', command], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); mpremoteQueue.setCurrentProcess(child); + let stderr = ''; child.stdout?.on('data', (d: Buffer) => { const s = d.toString(); outputChannel ? outputChannel.appendLine(s.trimEnd()) : console.log(s); }); child.stderr?.on('data', (d: Buffer) => { const s = d.toString(); + stderr += s; outputChannel ? outputChannel.appendLine(s.trimEnd()) : console.error(s); }); child.on('close', (code) => { mpremoteQueue.setCurrentProcess(null); - code === 0 ? resolve() : reject(new Error(`exit code ${code}`)); + code === 0 ? resolve() : reject(new Error(stderr.trim() || `exit code ${code}`)); }); child.on('error', (err) => { mpremoteQueue.setCurrentProcess(null); @@ -132,6 +134,27 @@ export function execMpremote(command: string): Promise { })); } +/** + * Executes a shell command outside the queue and returns stdout. + * Use for lightweight checks (e.g. checkMicroPython) that must not block the queue. + */ +export function execUnqueued(command: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', command], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); + child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + const timer = setTimeout(() => { killGroup(child); reject(new Error('timed out')); }, timeoutMs); + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0 || code === null) { resolve(stdout); } + else { reject(new Error(stderr.trim() || `exit code ${code}`)); } + }); + child.on('error', (err) => { clearTimeout(timer); reject(err); }); + }); +} + /** * Executes a shell command with a hard timeout. * NOT queued β€” used for time-sensitive stop/reset operations. @@ -149,6 +172,33 @@ export function execWithTimeout(cmd: string, ms: number): Promise { }); } +/** + * Retries an async function up to `retries` times with `delayMs` between attempts. + * Logs each failed non-final attempt to outputChannel. + * Throws the last error if all attempts are exhausted. + */ +export async function withRetry( + fn: () => Promise, + retries = 5, + delayMs = 500, + label = 'operation', + outputChannel?: OutputChannel +): Promise { + let lastError: Error = new Error('unknown'); + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err: any) { + lastError = err; + if (attempt < retries) { + outputChannel?.appendLine(`[RETRY ${attempt}/${retries}] ${label}: ${err.message}`); + await new Promise(r => setTimeout(r, delayMs)); + } + } + } + throw lastError; +} + /** * Sends SIGINT to a child process, waits for exit, falls back to SIGKILL after timeoutMs. */ From 43af5a1d5fbe10278b97f98caf08f39dda585334 Mon Sep 17 00:00:00 2001 From: Fran Fodor Date: Tue, 26 May 2026 13:13:15 +0200 Subject: [PATCH 07/11] add running file display --- src/handlers/serialHandler.ts | 3 +++ src/panel/index.html | 32 +++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/handlers/serialHandler.ts b/src/handlers/serialHandler.ts index c99b24b..c41308e 100644 --- a/src/handlers/serialHandler.ts +++ b/src/handlers/serialHandler.ts @@ -96,6 +96,7 @@ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Pr const child = spawn('mpremote', args, { shell: false }); ctx.setMpRunProc(child); + ctx.postMessage({ command: 'runStatus', running: true, filename }); child.stdout.on('data', (data: Buffer) => { ctx.outputChannel.append(data.toString('utf-8')); @@ -106,11 +107,13 @@ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Pr child.on('close', (code) => { ctx.outputChannel.appendLine(`\n[run exited with code ${code ?? 0}]`); ctx.setMpRunProc(null); + ctx.postMessage({ command: 'runStatus', running: false }); }); child.on('error', (err) => { vscode.window.showErrorMessage(`Failed to start mpremote run: ${err.message}`); ctx.outputChannel.appendLine(`[ERROR] Failed to start mpremote run: ${err.message}`); ctx.setMpRunProc(null); + ctx.postMessage({ command: 'runStatus', running: false }); }); vscode.window.showInformationMessage(`${filename} is running. Use "Stop" to interrupt.`); diff --git a/src/panel/index.html b/src/panel/index.html index b692c7c..7a5f2bf 100644 --- a/src/panel/index.html +++ b/src/panel/index.html @@ -341,6 +341,19 @@ line-height: 14px; } + .tree-file-indicator { + display: inline-block; + width: 14px; + flex-shrink: 0; + font-size: 9px; + text-align: center; + color: var(--vscode-charts-green, #4caf50); + } + + .tree-row.tree-running .tree-name { + color: var(--vscode-charts-green, #4caf50); + } + @@ -629,7 +642,7 @@

} else { const toggleSpacer = document.createElement('span'); - toggleSpacer.style.cssText = 'display:inline-block;width:14px;flex-shrink:0'; + toggleSpacer.className = 'tree-file-indicator'; row.appendChild(toggleSpacer); const label = document.createElement('span'); @@ -941,6 +954,23 @@

setFlashingState(state); } + if (message.command === 'runStatus') { + // Clear previous running indicator + document.querySelectorAll('#fileTree .tree-running').forEach(el => { + el.classList.remove('tree-running'); + const ind = el.querySelector('.tree-file-indicator'); + if (ind) ind.textContent = ''; + }); + if (message.running && message.filename) { + const row = document.querySelector(`#fileTree .tree-row[data-path="${CSS.escape(message.filename)}"]`); + if (row) { + row.classList.add('tree-running'); + const ind = row.querySelector('.tree-file-indicator'); + if (ind) ind.textContent = 'β–Ά'; + } + } + } + if (message.command === 'serialMonitorStatus') { serialMonitorActive = message.active; const btn = document.getElementById('serialMonitorBtn'); From 2122997b4a35a40c7cf3b368e17c083e46ed1c37 Mon Sep 17 00:00:00 2001 From: Fran Fodor Date: Tue, 26 May 2026 14:40:04 +0200 Subject: [PATCH 08/11] add serial monitor sending --- src/EspFlasherProvider.ts | 20 ++++ src/handlers/serialHandler.ts | 173 ++++++++++++++++++++++++++++------ src/panel/index.html | 27 ++++++ src/types.ts | 2 + 4 files changed, 194 insertions(+), 28 deletions(-) diff --git a/src/EspFlasherProvider.ts b/src/EspFlasherProvider.ts index 451b789..5767820 100644 --- a/src/EspFlasherProvider.ts +++ b/src/EspFlasherProvider.ts @@ -23,6 +23,7 @@ function filterPorts(ports: { path: string }[]): string[] { export class EspFlasherViewProvider implements vscode.WebviewViewProvider { private mpRunProc: ChildProcess | null = null; + private runSerial: SerialPort | null = null; private _view?: vscode.WebviewView; private outputChannel = vscode.window.createOutputChannel('ESP Output'); private serialMonitor: SerialPort | null = null; @@ -42,6 +43,8 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { setSerialMonitor: (s: SerialPort | null) => { self.serialMonitor = s; }, get mpRunProc() { return self.mpRunProc; }, setMpRunProc: (p: ChildProcess | null) => { self.mpRunProc = p; }, + get runSerial() { return self.runSerial; }, + setRunSerial: (s: SerialPort | null) => { self.runSerial = s; }, extensionContext: this.context, }; } @@ -220,6 +223,23 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { } break; + case 'sendSerial': + if (ctx.runSerial && ctx.runSerial.isOpen) { + // Raw REPL run: no newline β€” sys.stdin.read(1) reads exact chars + ctx.runSerial.write(message.data, (err) => { + if (err) ctx.outputChannel.appendLine(`[ERROR] Serial write failed: ${err.message}`); + }); + } else if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.serialMonitor.write(message.data + '\n', (err) => { + if (err) ctx.outputChannel.appendLine(`[ERROR] Serial write failed: ${err.message}`); + }); + } else if (ctx.mpRunProc?.stdin) { + ctx.mpRunProc.stdin.write(message.data + '\n', (err) => { + if (err) ctx.outputChannel.appendLine(`[ERROR] stdin write failed: ${err?.message}`); + }); + } + break; + case 'noop': break; diff --git a/src/handlers/serialHandler.ts b/src/handlers/serialHandler.ts index c41308e..3ded5e4 100644 --- a/src/handlers/serialHandler.ts +++ b/src/handlers/serialHandler.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as os from 'os'; -import { spawn } from 'child_process'; +import * as fs from 'fs'; import { SerialPort } from 'serialport'; import { HandlerContext } from '../types'; import { execCommand, execMpremote, execWithTimeout, killProc, withRetry } from '../utils/execUtils'; @@ -58,7 +58,8 @@ export function stopSerialMonitorAndReset(ctx: HandlerContext, portPath: string) } /** - * Downloads a device file to temp and runs it via mpremote, streaming output live. + * Downloads a device file to temp and runs it via raw REPL over serial. + * This gives full bidirectional stdin/stdout β€” sys.stdin.read() works. */ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Promise { if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { @@ -72,6 +73,11 @@ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Pr ctx.setMpRunProc(null); } + if (ctx.runSerial && ctx.runSerial.isOpen) { + ctx.runSerial.close(); + ctx.setRunSerial(null); + } + const { filename, port } = message; if (!filename || !port) { vscode.window.showErrorMessage('Filename and port are required to run the script.'); @@ -89,34 +95,129 @@ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Pr } ); + const scriptContent = fs.readFileSync(tempPath); + ctx.outputChannel.appendLine(`Running ${filename}`); ctx.outputChannel.show(true); - const args = ['connect', port, 'run', tempPath]; - const child = spawn('mpremote', args, { shell: false }); + const serial = new SerialPort({ path: port, baudRate: 115200, autoOpen: false }); - ctx.setMpRunProc(child); - ctx.postMessage({ command: 'runStatus', running: true, filename }); + serial.open((openErr) => { + if (openErr) { + vscode.window.showErrorMessage(`Failed to open serial port: ${openErr.message}`); + ctx.outputChannel.appendLine(`[ERROR] Failed to open serial port: ${openErr.message}`); + ctx.postMessage({ command: 'runStatus', running: false }); + return; + } - child.stdout.on('data', (data: Buffer) => { - ctx.outputChannel.append(data.toString('utf-8')); - }); - child.stderr.on('data', (data: Buffer) => { - ctx.outputChannel.append(data.toString('utf-8')); - }); - child.on('close', (code) => { - ctx.outputChannel.appendLine(`\n[run exited with code ${code ?? 0}]`); - ctx.setMpRunProc(null); - ctx.postMessage({ command: 'runStatus', running: false }); - }); - child.on('error', (err) => { - vscode.window.showErrorMessage(`Failed to start mpremote run: ${err.message}`); - ctx.outputChannel.appendLine(`[ERROR] Failed to start mpremote run: ${err.message}`); - ctx.setMpRunProc(null); - ctx.postMessage({ command: 'runStatus', running: false }); - }); + ctx.setRunSerial(serial); + ctx.postMessage({ command: 'runStatus', running: true, filename }); + vscode.window.showInformationMessage(`${filename} is running. Use "Stop" to interrupt.`); + + // State machine: waiting_prompt β†’ waiting_ok β†’ running + let state: 'waiting_prompt' | 'waiting_ok' | 'running' = 'waiting_prompt'; + let buf = ''; + let isDone = false; + + // Timeout if raw REPL prompt never arrives (board unresponsive) + const promptTimeout = setTimeout(() => { + if (state !== 'waiting_prompt') { return; } + ctx.outputChannel.appendLine('[ERROR] Timeout: raw REPL prompt not received. Board may be unresponsive.'); + vscode.window.showErrorMessage('Failed to enter raw REPL mode. Board may be unresponsive.'); + serial.close(); + }, 3000); + + const finish = () => { + if (isDone) { return; } + isDone = true; + ctx.setRunSerial(null); + ctx.postMessage({ command: 'runStatus', running: false }); + }; + + serial.on('close', () => { + clearTimeout(promptTimeout); + finish(); + ctx.outputChannel.appendLine('\n[run finished]'); + setTimeout(() => { + startSerialMonitor(ctx, port); + ctx.outputChannel.appendLine('Serial monitor reopened.'); + }, 250); + }); + + serial.on('error', (err: Error) => { + clearTimeout(promptTimeout); + ctx.outputChannel.appendLine(`[ERROR] Serial error during run: ${err.message}`); + finish(); + }); + + // Processes output in 'running' state β€” strips \x04 protocol bytes, + // detects end-of-execution (\x04[stderr]\x04) to close cleanly. + const processRunningData = (chunk: string) => { + buf += chunk; - vscode.window.showInformationMessage(`${filename} is running. Use "Stop" to interrupt.`); + const firstEot = buf.indexOf('\x04'); + if (firstEot === -1) { + // No end marker β€” flush all output + ctx.outputChannel.append(buf); + buf = ''; + return; + } + + // Output everything before the first end-of-stdout marker + if (firstEot > 0) { + ctx.outputChannel.append(buf.slice(0, firstEot)); + } + + // Check for second EOT: end of stderr β€” script execution complete + const secondEot = buf.indexOf('\x04', firstEot + 1); + if (secondEot !== -1) { + const stderrOut = buf.slice(firstEot + 1, secondEot); + if (stderrOut.trim()) { + ctx.outputChannel.append(stderrOut); + } + buf = ''; + finish(); + serial.close(); // triggers 'close' β†’ logs, reopens monitor + } else { + // Have first \x04 but not second yet β€” keep buffered + buf = buf.slice(firstEot); + } + }; + + serial.on('data', (data: Buffer) => { + const chunk = data.toString('utf-8'); + + if (state === 'waiting_prompt') { + buf += chunk; + // Raw REPL mode prompt ends with \r\n> + if (buf.includes('\r\n>')) { + clearTimeout(promptTimeout); + state = 'waiting_ok'; + buf = ''; + serial.write(scriptContent); + serial.write(Buffer.from([0x04])); // Ctrl-D: execute + } + return; + } + + if (state === 'waiting_ok') { + buf += chunk; + const okIdx = buf.indexOf('OK'); + if (okIdx !== -1) { + state = 'running'; + const rest = buf.slice(okIdx + 2); + buf = ''; + if (rest) { processRunningData(rest); } + } + return; + } + + processRunningData(chunk); + }); + + // Interrupt any running code, then enter raw REPL mode + serial.write(Buffer.from([0x03, 0x03, 0x01])); // Ctrl-C, Ctrl-C, Ctrl-A + }); } /** @@ -130,13 +231,29 @@ export async function handleStopRunningCode(ctx: HandlerContext, message: any): return; } - // 1) Kill active mpremote run process + // 1) Stop raw REPL run (primary path β€” handleRunPythonFile sets runSerial) + if (ctx.runSerial) { + try { + if (ctx.runSerial.isOpen) { + ctx.runSerial.write(Buffer.from([0x03, 0x03])); // Ctrl-C to interrupt + await new Promise(r => setTimeout(r, 200)); + ctx.runSerial.close(); // 'close' event in handleRunPythonFile handles cleanup + monitor reopen + } + } catch (e: any) { + ctx.outputChannel.appendLine(`[WARN] Error stopping run: ${e.message}`); + ctx.setRunSerial(null); + ctx.postMessage({ command: 'runStatus', running: false }); + } + return; + } + + // 2) Legacy path: kill mpremote run process if (ctx.mpRunProc) { await killProc(ctx.mpRunProc); ctx.setMpRunProc(null); } - // 2) Close serial monitor to free the port + // 3) Close serial monitor to free the port if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { ctx.serialMonitor.close(); ctx.setSerialMonitor(null); @@ -145,7 +262,7 @@ export async function handleStopRunningCode(ctx: HandlerContext, message: any): // Short grace so OS releases the port handle await new Promise(r => setTimeout(r, 150)); - // 3) Send Ctrl-C, Ctrl-C, Ctrl-D directly over serial + // 4) Send Ctrl-C, Ctrl-C, Ctrl-D directly over serial try { await new Promise((resolve, reject) => { const tmp = new SerialPort({ path: port, baudRate: 115200, autoOpen: false }); @@ -176,7 +293,7 @@ export async function handleStopRunningCode(ctx: HandlerContext, message: any): } } - // 4) Optionally reopen the serial monitor + // 5) Optionally reopen the serial monitor if (keepMonitor) { await new Promise(r => setTimeout(r, 250)); startSerialMonitor(ctx, port); diff --git a/src/panel/index.html b/src/panel/index.html index 7a5f2bf..3d27be7 100644 --- a/src/panel/index.html +++ b/src/panel/index.html @@ -489,6 +489,12 @@

+
@@ -796,6 +802,12 @@

// Serial Monitor Button let serialMonitorActive = false; +let scriptRunning = false; + +function updateSendRowVisibility() { + const sendRow = document.getElementById('serialSendRow'); + if (sendRow) sendRow.style.display = (serialMonitorActive || scriptRunning) ? 'block' : 'none'; +} document.getElementById('serialMonitorBtn').addEventListener('click', () => { const port = document.getElementById('port').value; @@ -807,6 +819,18 @@

} }); +document.getElementById('serialSendBtn').addEventListener('click', () => { + const input = document.getElementById('serialSendInput'); + const port = document.getElementById('port').value; + if (!port || !input.value) return; + vscode.postMessage({ command: 'sendSerial', port, data: input.value }); + input.value = ''; +}); + +document.getElementById('serialSendInput').addEventListener('keydown', e => { + if (e.key === 'Enter') document.getElementById('serialSendBtn').click(); +}); + document.getElementById('stopCodeBtn').addEventListener('click', () => { const port = document.getElementById('port').value; if (!port) return; @@ -955,6 +979,8 @@

} if (message.command === 'runStatus') { + scriptRunning = message.running; + updateSendRowVisibility(); // Clear previous running indicator document.querySelectorAll('#fileTree .tree-running').forEach(el => { el.classList.remove('tree-running'); @@ -975,6 +1001,7 @@

serialMonitorActive = message.active; const btn = document.getElementById('serialMonitorBtn'); if (btn) btn.textContent = message.active ? 'Stop Serial Monitor' : 'Serial Monitor'; + updateSendRowVisibility(); } if (message.command === 'micropythonStatus') { diff --git a/src/types.ts b/src/types.ts index 24c77f5..b669a84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,5 +13,7 @@ export interface HandlerContext { setSerialMonitor(s: SerialPort | null): void; readonly mpRunProc: ChildProcess | null; setMpRunProc(p: ChildProcess | null): void; + readonly runSerial: SerialPort | null; + setRunSerial(s: SerialPort | null): void; extensionContext: vscode.ExtensionContext; } From a02583ba3d1477251bc84b5f3a0173619e40fb26 Mon Sep 17 00:00:00 2001 From: Fran Fodor Date: Tue, 26 May 2026 14:48:14 +0200 Subject: [PATCH 09/11] make saving more robust --- package.json | 5 - src/EspFlasherProvider.ts | 8 +- src/extension.ts | 238 +++++++++++----------------------- src/handlers/uploadHandler.ts | 70 +++++----- src/utils/portUtils.ts | 36 ----- src/utils/uploadUtils.ts | 20 +++ 6 files changed, 131 insertions(+), 246 deletions(-) delete mode 100644 src/utils/portUtils.ts create mode 100644 src/utils/uploadUtils.ts diff --git a/package.json b/package.json index 3008db6..56e8fd0 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,6 @@ "default": true, "description": "When saving a Python file, also upload it to the connected MicroPython device (using the last selected port). If disabled, you will be prompted to choose where to save the file (PC, device, or both)." }, - "mp.alsoSaveLocally": { - "type": "boolean", - "default": false, - "description": "When uploading to device on save, also save the file locally to disk." - }, "mp.savePromptMode": { "type": "string", "enum": [ diff --git a/src/EspFlasherProvider.ts b/src/EspFlasherProvider.ts index 5767820..af0bb51 100644 --- a/src/EspFlasherProvider.ts +++ b/src/EspFlasherProvider.ts @@ -8,9 +8,9 @@ import { HandlerContext } from './types'; import { startSerialMonitor, handleRunPythonFile, handleStopRunningCode } from './handlers/serialHandler'; import { handleFlashFromWeb, handleFlashFirmware, fetchFirmwareList } from './handlers/flashHandler'; import { handleListFiles, handleDeleteFile, handleDeleteAllFiles } from './handlers/fileHandler'; -import { handleUploadPython, handleUploadPythonAsIs, handleUploadPythonFromPc, handleOpenFileFromDevice } from './handlers/uploadHandler'; +import { handleUploadPythonAsIs, handleUploadPythonFromPc, handleOpenFileFromDevice } from './handlers/uploadHandler'; import { handleFetchModule, handleGetCategories, handleGetModulesForCategory, handleGetAllModules } from './handlers/moduleHandler'; -import { execMpremote, execUnqueued } from './utils/execUtils'; +import { execUnqueued } from './utils/execUtils'; const IGNORED_PORT_PATTERNS = ['debug-console', 'Bluetooth-Incoming-Port']; @@ -122,10 +122,6 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { await this.refreshState(); break; - case 'uploadPython': - await handleUploadPython(ctx, message); - break; - case 'listFiles': handleListFiles(ctx, message); break; diff --git a/src/extension.ts b/src/extension.ts index 25422cb..bd56541 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,13 +2,10 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import * as os from 'os'; -import * as fs from 'fs'; -import { SerialPort } from 'serialport'; import { EspFlasherViewProvider } from './EspFlasherProvider'; -import { pickPort } from './utils/portUtils'; -import { execCommand } from './utils/execUtils'; +import { uploadFileToDevice } from './utils/uploadUtils'; +import { lookupDeviceFile } from './handlers/uploadHandler'; export function activate(context: vscode.ExtensionContext) { const provider = new EspFlasherViewProvider(context); @@ -19,182 +16,99 @@ export function activate(context: vscode.ExtensionContext) { const out = provider.getOutputChannel(); - // Narrowed save targets - type SaveMode = 'pc' | 'device' | 'both'; - type SavePromptMode = 'ask' | SaveMode; - - /** - * Resolves the final SaveMode from extension settings. - * - If "save to device on save" is enabled, optionally also save locally. - * - Otherwise respects "savePromptMode" (and prompts if "ask"). - */ - const resolveSaveMode = async (cfg: vscode.WorkspaceConfiguration): Promise => { - const saveToDeviceOnSave = cfg.get('mp.saveToDeviceOnSave', true); - const alsoSaveLocally = cfg.get('mp.alsoSaveLocally', false); - - if (saveToDeviceOnSave) { - return alsoSaveLocally ? 'both' : 'device'; - } - - let m = cfg.get('mp.savePromptMode', 'ask'); - if (m === 'ask') { - const pick = await vscode.window.showQuickPick( - [ - { label: 'Save to PC', value: 'pc' as const }, - { label: 'Save to device', value: 'device' as const }, - { label: 'Save to both', value: 'both' as const }, - ], - { placeHolder: 'Where do you want to save this .py?' } - ); - if (!pick) throw new Error('cancelled'); - return pick.value; - } - - return m; - }; - /** * Command: mp.savePython * Triggered by Ctrl+S / Cmd+S on Python files. - * Saves to PC, device, or both based on settings. + * + * Flow: + * 1. Always save to disk first β€” PC always has the latest version. + * 2. If a device port is selected and upload is configured, upload to device. + * - If file was opened from device, uploads back to its original device path. + * - If no port is connected, falls back silently to PC-only with status bar hint. */ context.subscriptions.push( vscode.commands.registerCommand('mp.savePython', async () => { out.appendLine('mp.savePython invoked'); - out.show(true); - const editor0 = vscode.window.activeTextEditor; - if (!editor0 || editor0.document.languageId !== 'python') { - out.appendLine('No Python editor active - falling back to normal save'); + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'python') { await vscode.commands.executeCommand('workbench.action.files.save'); return; } - const cfg = vscode.workspace.getConfiguration(); - const saveAsMain = cfg.get('mp.saveDeviceAsMain', false); - - let mode: SaveMode; - try { - mode = await resolveSaveMode(cfg); - out.appendLine(`Resolved save mode: ${mode}`); - } catch (e) { - const msg = (e as Error).message; - if (msg === 'cancelled') { - out.appendLine('User cancelled save mode quick pick.'); - return; - } - vscode.window.showErrorMessage(`Save error: ${msg}`); - out.appendLine(`[ERROR] Resolve mode error: ${msg}`); - return; + const cfg = vscode.workspace.getConfiguration(); + const saveToDevice = cfg.get('mp.saveToDeviceOnSave', true); + const saveAsMain = cfg.get('mp.saveDeviceAsMain', false); + const savePromptMode = cfg.get('mp.savePromptMode', 'ask'); + + let doc = editor.document; + + // Step 1: Always save to disk + if (doc.isUntitled) { + const uri = await vscode.window.showSaveDialog({ filters: { Python: ['py'] } }); + if (!uri) { return; } + await vscode.workspace.fs.writeFile(uri, Buffer.from(doc.getText(), 'utf8')); + const diskDoc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(diskDoc, { preview: false }); + doc = diskDoc; + } else { + await vscode.commands.executeCommand('workbench.action.files.save'); + doc = vscode.window.activeTextEditor?.document || doc; } - - let doc = editor0.document; - - /** - * Ensures the file is persisted to disk. - * For untitled files: prompts Save As. - * For titled files: triggers normal save. - */ - const ensureOnDisk = async (): Promise => { - if (doc.isUntitled) { - out.appendLine('Document is untitled - prompting for save location...'); - const uri = await vscode.window.showSaveDialog({ filters: { Python: ['py'] } }); - if (!uri) { - out.appendLine('User cancelled Save Dialog.'); - return false; - } - await vscode.workspace.fs.writeFile(uri, Buffer.from(doc.getText(), 'utf8')); - const diskDoc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(diskDoc, { preview: false }); - out.appendLine(`Saved untitled doc to: ${uri.fsPath}`); - } else { - out.appendLine(`Saving to disk: ${doc.fileName}`); - await vscode.commands.executeCommand('workbench.action.files.save'); + out.appendLine(`Saved to disk: ${doc.fileName}`); + + // Step 2: Decide whether to upload to device + let shouldUpload = false; + if (saveToDevice) { + shouldUpload = true; + } else { + let mode = savePromptMode; + if (mode === 'ask') { + // File is already on disk β€” only ask about device upload + const pick = await vscode.window.showQuickPick( + [ + { label: 'PC only', description: 'Already saved to disk', value: 'pc' }, + { label: 'Also upload to device', value: 'device' }, + ], + { placeHolder: 'File saved to PC β€” also upload to connected device?' } + ); + if (!pick) { return; } + mode = (pick as any).value; } + shouldUpload = (mode === 'device' || mode === 'both'); + } - const ed = vscode.window.activeTextEditor; - if (!ed) { - out.appendLine('[ERROR] No active editor after save.'); - return false; - } - doc = ed.document; - return true; - }; - - /** - * Uploads the current file/buffer to the device via mpremote. - */ - const uploadToDevice = async () => { - const port = await pickPort({ outputChannel: out, extensionContext: context }); - if (!port) return; - - if (mode === 'device') { - const tmpDir = path.join(os.tmpdir(), 'mp-save'); - await fs.promises.mkdir(tmpDir, { recursive: true }); - - const fname = saveAsMain ? 'main.py' : (path.basename(doc.fileName || 'code.py') || 'code.py'); - const tmpPath = path.join(tmpDir, fname); - - await fs.promises.writeFile(tmpPath, doc.getText(), 'utf8'); - out.appendLine(`Uploading buffer - temp: ${tmpPath} - device: ${fname} on ${port}`); - - const cmd = `mpremote connect ${port} fs cp "${tmpPath}" :${saveAsMain ? 'main.py' : `"${fname}"`}`; - out.appendLine(`Exec: ${cmd}`); - try { - await execCommand(cmd, out); - out.appendLine('Upload successful (device-only).'); - } catch (err: any) { - out.appendLine(`[ERROR] Upload failed: ${err.message}`); - throw err; - } - - vscode.window.showInformationMessage(`Saved to device (${fname}).`); - provider.refreshFileListOnDevice(port); - return; - } + if (!shouldUpload) { + vscode.window.setStatusBarMessage('Saved to PC', 1500); + return; + } - const ok = await ensureOnDisk(); - if (!ok) return; - - const localPath = doc.fileName; - const deviceName = saveAsMain ? 'main.py' : path.basename(localPath); - out.appendLine(`Uploading disk file: ${localPath} - device: ${deviceName} on ${port}`); - - const cmd2 = `mpremote connect ${port} fs cp "${localPath}" :${saveAsMain ? 'main.py' : `"${deviceName}"`}`; - out.appendLine(`Exec: ${cmd2}`); - try { - await execCommand(cmd2, out); - out.appendLine('Upload successful (pc/both).'); - } catch (err: any) { - out.appendLine(`[ERROR] Upload failed: ${err.message}`); - throw err; - } + // Step 3: Get port β€” no disruptive prompt; fall back silently if missing + const port = context.globalState.get('mp.lastPort'); + if (!port) { + vscode.window.setStatusBarMessage('Saved locally β€” no device connected', 3000); + out.appendLine('[INFO] No port selected β€” saved to PC only.'); + return; + } - vscode.window.showInformationMessage(`Uploaded to device (${deviceName}).`); - provider.refreshFileListOnDevice(port); - }; + // Step 4: Determine device path + // Files opened from device have a registered path that preserves their subdirectory + const deviceInfo = lookupDeviceFile(doc.fileName); + const devicePath = deviceInfo + ? deviceInfo.devicePath + : saveAsMain ? '/main.py' : '/' + path.basename(doc.fileName); - // Execute the chosen flow + // Step 5: Upload try { - if (mode === 'pc') { - const ok = await ensureOnDisk(); - if (!ok) return; - out.appendLine('Done: saved to PC only.'); - vscode.window.setStatusBarMessage('Saved to PC', 1500); - - } else if (mode === 'device') { - await uploadToDevice(); - - } else if (mode === 'both') { - const ok = await ensureOnDisk(); - if (!ok) return; - await uploadToDevice(); - } - - } catch (e: any) { - const msg = e?.message || String(e); - vscode.window.showErrorMessage(`Save error: ${msg}`); - out.appendLine(`[ERROR] Save flow error: ${msg}`); + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Uploading to device...', cancellable: false }, + async () => { await uploadFileToDevice(doc.fileName, devicePath, port, out); } + ); + vscode.window.showInformationMessage(`Uploaded to device (${path.basename(devicePath)}).`); + provider.refreshFileListOnDevice(port); + } catch (err: any) { + vscode.window.showErrorMessage(`Upload failed: ${err.message}`); + out.appendLine(`[ERROR] Upload error: ${err.message}`); } }) ); diff --git a/src/handlers/uploadHandler.ts b/src/handlers/uploadHandler.ts index aba2b0e..0e59b20 100644 --- a/src/handlers/uploadHandler.ts +++ b/src/handlers/uploadHandler.ts @@ -4,43 +4,26 @@ import * as path from 'path'; import * as os from 'os'; import { HandlerContext } from '../types'; import { execCommand, execMpremote, withRetry } from '../utils/execUtils'; +import { uploadFileToDevice } from '../utils/uploadUtils'; /** - * Uploads the active editor's Python file to the device as main.py. + * Tracks files downloaded from the device so Ctrl+S can upload back + * to the correct device path (including subdirectories). + * Key: local temp path. Value: { port, devicePath }. */ -export async function handleUploadPython(ctx: HandlerContext, message: any): Promise { - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } +const deviceFileRegistry = new Map(); - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor || activeEditor.document.languageId !== 'python') { - vscode.window.showErrorMessage('No active Python file to upload.'); - return; - } - - const filePath = activeEditor.document.fileName; - const { port } = message; - const uploadCmd = `mpremote connect ${port} fs cp "${filePath}" :main.py`; +export function registerDeviceFile(localPath: string, port: string, devicePath: string): void { + deviceFileRegistry.set(localPath, { port, devicePath }); +} - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Uploading Python file as main.py...', cancellable: false }, - async () => { - try { - await withRetry(() => execCommand(uploadCmd, ctx.outputChannel), 5, 500, 'uploadPython', ctx.outputChannel); - vscode.window.showInformationMessage('Python file uploaded successfully as main.py!'); - ctx.postMessage({ command: 'triggerListFiles', port }); - } catch (err: any) { - vscode.window.showErrorMessage(`Upload failed: ${err.message}`); - } - } - ); +export function lookupDeviceFile(localPath: string): { port: string; devicePath: string } | undefined { + return deviceFileRegistry.get(localPath); } /** * Uploads the active editor's Python file to the device preserving its original filename. + * Flushes any unsaved buffer changes to disk first so the device gets the latest content. */ export async function handleUploadPythonAsIs(ctx: HandlerContext, message: any): Promise { if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { @@ -55,20 +38,32 @@ export async function handleUploadPythonAsIs(ctx: HandlerContext, message: any): return; } - const filePath = activeEditor.document.fileName; - const fileName = filePath.split(/[/\\]/).pop()!; + const doc = activeEditor.document; + + if (doc.isUntitled) { + vscode.window.showErrorMessage('Save the file locally first before uploading to device.'); + return; + } + + // Flush buffer to disk so the device receives the current editor content + if (doc.isDirty) { + await vscode.workspace.save(doc.uri); + } + + const filePath = doc.fileName; + const fileName = path.basename(filePath); const { port } = message; - const uploadCmd = `mpremote connect ${port} fs cp "${filePath}" :"${fileName}"`; await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Uploading ${fileName} to device...`, cancellable: false }, async () => { try { - await withRetry(() => execCommand(uploadCmd, ctx.outputChannel), 5, 500, `upload:${fileName}`, ctx.outputChannel); + await uploadFileToDevice(filePath, `/${fileName}`, port, ctx.outputChannel); vscode.window.showInformationMessage(`${fileName} uploaded successfully!`); ctx.postMessage({ command: 'triggerListFiles', port }); } catch (err: any) { vscode.window.showErrorMessage(`Upload failed: ${err.message}`); + ctx.outputChannel.appendLine(`[ERROR] Upload error: ${err.message}`); } } ); @@ -76,6 +71,7 @@ export async function handleUploadPythonAsIs(ctx: HandlerContext, message: any): /** * Prompts user to pick a single .py file or folder, then uploads to device. + * Preserves directory structure relative to the selected folder root. */ export async function handleUploadPythonFromPc(ctx: HandlerContext, message: any): Promise { if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { @@ -166,12 +162,8 @@ export async function handleUploadPythonFromPc(ctx: HandlerContext, message: any ctx.outputChannel ).catch(() => {}); } - // Upload files preserving relative paths for (const { localPath, devicePath } of uploadFiles) { - await withRetry( - () => execCommand(`mpremote connect ${port} fs cp "${localPath}" :"${devicePath}"`, ctx.outputChannel), - 5, 500, `upload:${devicePath}`, ctx.outputChannel - ); + await uploadFileToDevice(localPath, devicePath, port, ctx.outputChannel); } vscode.window.showInformationMessage('All .py files uploaded successfully!'); ctx.postMessage({ command: 'triggerListFiles', port }); @@ -185,6 +177,7 @@ export async function handleUploadPythonFromPc(ctx: HandlerContext, message: any /** * Downloads a file from the device to a temp location and opens it in the editor. + * Registers the device path so Ctrl+S uploads back to the correct location. */ export async function handleOpenFileFromDevice(ctx: HandlerContext, message: any): Promise { const { port, filename } = message; @@ -198,6 +191,9 @@ export async function handleOpenFileFromDevice(ctx: HandlerContext, message: any await withRetry(() => execMpremote(cmd), 5, 500, 'openFile', ctx.outputChannel); + // Track mapping: local temp path β†’ device path so Ctrl+S uploads back correctly + registerDeviceFile(localPath, port, filename); + const doc = await vscode.workspace.openTextDocument(localPath); await vscode.window.showTextDocument(doc, { preview: false }); vscode.window.showInformationMessage(`Opened ${filename} from device.`); diff --git a/src/utils/portUtils.ts b/src/utils/portUtils.ts deleted file mode 100644 index 795b120..0000000 --- a/src/utils/portUtils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as vscode from 'vscode'; -import { SerialPort } from 'serialport'; -import { HandlerContext } from '../types'; - -/** - * Returns the last-used port from globalState, or prompts the user to pick one. - * Saves the selection to globalState for future reuse. - */ -export async function pickPort(ctx: Pick): Promise { - const lastPort = ctx.extensionContext.globalState.get('mp.lastPort'); - if (lastPort) { - ctx.outputChannel.appendLine(`Using last selected port: ${lastPort}`); - return lastPort; - } - - const ports = await SerialPort.list(); - if (!ports.length) { - vscode.window.showErrorMessage('No serial ports available.'); - ctx.outputChannel.appendLine('[ERROR] No serial ports available.'); - return undefined; - } - - const chosen = await vscode.window.showQuickPick( - ports.map(p => p.path), - { placeHolder: 'Select a serial port' } - ); - - if (chosen) { - await ctx.extensionContext.globalState.update('mp.lastPort', chosen); - ctx.outputChannel.appendLine(`Selected port: ${chosen} (saved as lastPort)`); - return chosen; - } - - ctx.outputChannel.appendLine('User cancelled port selection.'); - return undefined; -} diff --git a/src/utils/uploadUtils.ts b/src/utils/uploadUtils.ts new file mode 100644 index 0000000..d85fbbf --- /dev/null +++ b/src/utils/uploadUtils.ts @@ -0,0 +1,20 @@ +import type { OutputChannel } from 'vscode'; +import { execCommand, withRetry } from './execUtils'; + +/** + * Uploads a local file to the connected device at devicePath. + * Retries up to 5 times on transient mpremote failures. + */ +export async function uploadFileToDevice( + localPath: string, + devicePath: string, + port: string, + outputChannel: OutputChannel +): Promise { + const cmd = `mpremote connect ${port} fs cp "${localPath}" :"${devicePath}"`; + outputChannel.appendLine(`Uploading ${localPath} β†’ device:${devicePath}`); + await withRetry( + () => execCommand(cmd, outputChannel), + 5, 500, `upload:${devicePath}`, outputChannel + ); +} From 8cf7bfb7efeacba791a84be54046783624d7a568 Mon Sep 17 00:00:00 2001 From: Fran Fodor Date: Wed, 27 May 2026 07:42:16 +0200 Subject: [PATCH 10/11] add run to section title --- src/panel/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panel/index.html b/src/panel/index.html index 3d27be7..a23d3f6 100644 --- a/src/panel/index.html +++ b/src/panel/index.html @@ -464,7 +464,7 @@

- β–Ά Upload & Manage Files on Board + β–Ά Run & Manage Files

From 4a04e5a572470c115f188755d60f584b697cdf20 Mon Sep 17 00:00:00 2001 From: Fran Fodor Date: Tue, 2 Jun 2026 11:01:30 +0200 Subject: [PATCH 11/11] fix port usage when file saving --- src/EspFlasherProvider.ts | 20 +++++++--- src/extension.ts | 8 +++- src/handlers/fileHandler.ts | 15 +++----- src/handlers/flashHandler.ts | 13 ++----- src/handlers/moduleHandler.ts | 7 +--- src/handlers/serialHandler.ts | 71 +++++++++++++++++++++++------------ src/handlers/uploadHandler.ts | 15 +++----- 7 files changed, 82 insertions(+), 67 deletions(-) diff --git a/src/EspFlasherProvider.ts b/src/EspFlasherProvider.ts index af0bb51..815d91a 100644 --- a/src/EspFlasherProvider.ts +++ b/src/EspFlasherProvider.ts @@ -5,12 +5,12 @@ import { SerialPort } from 'serialport'; import { ChildProcess } from 'child_process'; import { HandlerContext } from './types'; -import { startSerialMonitor, handleRunPythonFile, handleStopRunningCode } from './handlers/serialHandler'; +import { startSerialMonitor, handleRunPythonFile, handleStopRunningCode, closeAllSerial } from './handlers/serialHandler'; import { handleFlashFromWeb, handleFlashFirmware, fetchFirmwareList } from './handlers/flashHandler'; import { handleListFiles, handleDeleteFile, handleDeleteAllFiles } from './handlers/fileHandler'; import { handleUploadPythonAsIs, handleUploadPythonFromPc, handleOpenFileFromDevice } from './handlers/uploadHandler'; import { handleFetchModule, handleGetCategories, handleGetModulesForCategory, handleGetAllModules } from './handlers/moduleHandler'; -import { execUnqueued } from './utils/execUtils'; +import { execMpremote } from './utils/execUtils'; const IGNORED_PORT_PATTERNS = ['debug-console', 'Bluetooth-Incoming-Port']; @@ -64,6 +64,14 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { return this.outputChannel; } + /** + * Closes all active serial connections so the port is free for external callers + * (e.g. mp.savePython in extension.ts which calls uploadFileToDevice directly). + */ + public async releasePort(): Promise { + await closeAllSerial(this.getHandlerContext()); + } + /** * Fetches available serial ports and sends them to the webview. * Also triggers an initial file list refresh on the first port found. @@ -123,7 +131,7 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { break; case 'listFiles': - handleListFiles(ctx, message); + await handleListFiles(ctx, message); break; case 'getFirmwareOptions': { @@ -193,7 +201,7 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { break; case 'deleteFile': - handleDeleteFile(ctx, message); + await handleDeleteFile(ctx, message); break; case 'deleteAllFiles': @@ -201,8 +209,8 @@ export class EspFlasherViewProvider implements vscode.WebviewViewProvider { break; case 'checkMicroPython': - // Run outside the queue β€” just a lightweight check, must not block other operations - execUnqueued(`mpremote connect ${port} exec "import sys; print(sys.implementation.name)"`, 5000) + await closeAllSerial(ctx); + execMpremote(`mpremote connect ${port} exec "import sys; print(sys.implementation.name)"`) .then(stdout => { const installed = stdout.trim().toLowerCase().includes('micropython'); this._view?.webview.postMessage({ command: 'micropythonStatus', installed }); diff --git a/src/extension.ts b/src/extension.ts index bd56541..2791c7b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,8 +7,11 @@ import { EspFlasherViewProvider } from './EspFlasherProvider'; import { uploadFileToDevice } from './utils/uploadUtils'; import { lookupDeviceFile } from './handlers/uploadHandler'; +let activeProvider: EspFlasherViewProvider | undefined; + export function activate(context: vscode.ExtensionContext) { const provider = new EspFlasherViewProvider(context); + activeProvider = provider; context.subscriptions.push( vscode.window.registerWebviewViewProvider('espFlasherWebview', provider) @@ -100,6 +103,7 @@ export function activate(context: vscode.ExtensionContext) { // Step 5: Upload try { + await provider.releasePort(); await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: 'Uploading to device...', cancellable: false }, async () => { await uploadFileToDevice(doc.fileName, devicePath, port, out); } @@ -114,4 +118,6 @@ export function activate(context: vscode.ExtensionContext) { ); } -export function deactivate() {} +export async function deactivate() { + await activeProvider?.releasePort(); +} diff --git a/src/handlers/fileHandler.ts b/src/handlers/fileHandler.ts index d88c1ee..d8ffcb3 100644 --- a/src/handlers/fileHandler.ts +++ b/src/handlers/fileHandler.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import * as os from 'os'; import { HandlerContext } from '../types'; import { execMpremote, withRetry } from '../utils/execUtils'; +import { closeAllSerial } from './serialHandler'; /** * Lists files on the connected MicroPython device as a recursive tree and sends to the webview. @@ -30,6 +31,8 @@ def tree(p): print(json.dumps(tree('/'))) `; + await closeAllSerial(ctx); + const tempPath = path.join(os.tmpdir(), '__list_files__.py'); try { @@ -63,11 +66,7 @@ export async function handleDeleteFile(ctx: HandlerContext, message: any): Promi if (confirm !== 'Delete') return; } - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before deleting...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); const b64name = Buffer.from(filename).toString('base64'); const script = `import os, ubinascii @@ -101,11 +100,7 @@ rm(ubinascii.a2b_base64('${b64name}').decode()) export async function handleDeleteAllFiles(ctx: HandlerContext, message: any): Promise { const { port } = message; - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before deleting...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); const script = ` import os diff --git a/src/handlers/flashHandler.ts b/src/handlers/flashHandler.ts index 19d3827..d81b43e 100644 --- a/src/handlers/flashHandler.ts +++ b/src/handlers/flashHandler.ts @@ -7,6 +7,7 @@ import * as cheerio from 'cheerio'; import { exec, spawn } from 'child_process'; import { HandlerContext } from '../types'; import { mpremoteQueue } from '../utils/execUtils'; +import { closeAllSerial } from './serialHandler'; /** * Returns the correct flash start address for the given firmware filename. @@ -141,11 +142,7 @@ function streamFlashWithProgress(command: string, ctx: HandlerContext): Promise< * Supports UF2 (RP boards) and .bin (ESP32) formats. */ export async function handleFlashFromWeb(ctx: HandlerContext, firmwareUrl: string, port: string): Promise { - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before flashing...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); mpremoteQueue.abort(); await new Promise(r => setTimeout(r, 500)); // let OS release the port @@ -222,11 +219,7 @@ export async function handleFlashFromWeb(ctx: HandlerContext, firmwareUrl: strin * Handles flashing a locally selected .bin firmware file. */ export async function handleFlashFirmware(ctx: HandlerContext, message: any): Promise { - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before flashing...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); mpremoteQueue.abort(); await new Promise(r => setTimeout(r, 500)); // let OS release the port diff --git a/src/handlers/moduleHandler.ts b/src/handlers/moduleHandler.ts index 80506e2..5b28963 100644 --- a/src/handlers/moduleHandler.ts +++ b/src/handlers/moduleHandler.ts @@ -5,6 +5,7 @@ import * as https from 'https'; import { HandlerContext } from '../types'; import { execCommand } from '../utils/execUtils'; import { downloadFile } from './flashHandler'; +import { closeAllSerial } from './serialHandler'; const REPO_ROOT = 'https://api.github.com/repos/SolderedElectronics/Soldered-MicroPython-Modules/contents'; const FALLBACK_CATEGORIES = ['Sensors', 'Displays', 'Actuators']; @@ -279,11 +280,7 @@ async function uploadExamplesWithPicker(url: string, port: string, sensor: strin export async function handleFetchModule(ctx: HandlerContext, message: any): Promise { const { sensor, port, mode } = message; - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before fetching module...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); if (!sensor || !port) { vscode.window.showErrorMessage('Module name and port are required.'); diff --git a/src/handlers/serialHandler.ts b/src/handlers/serialHandler.ts index 3ded5e4..35a0111 100644 --- a/src/handlers/serialHandler.ts +++ b/src/handlers/serialHandler.ts @@ -7,15 +7,45 @@ import { HandlerContext } from '../types'; import { execCommand, execMpremote, execWithTimeout, killProc, withRetry } from '../utils/execUtils'; /** - * Starts the serial monitor on the given port. - * Closes any existing monitor first. + * Closes all active serial connections (monitor + run serial) and kills any + * running mpremote process. Awaits both closes so the OS releases the port + * before the caller proceeds. */ -export function startSerialMonitor(ctx: HandlerContext, portPath: string): void { +export async function closeAllSerial(ctx: HandlerContext): Promise { + if (ctx.mpRunProc) { + try { ctx.mpRunProc.kill(); } catch {} + ctx.setMpRunProc(null); + } + + const closes: Promise[] = []; + + if (ctx.runSerial) { + const serial = ctx.runSerial; + ctx.setRunSerial(null); + ctx.postMessage({ command: 'runStatus', running: false }); + if (serial.isOpen) { + closes.push(new Promise(resolve => serial.close(() => resolve()))); + } + } + if (ctx.serialMonitor) { - ctx.serialMonitor.close(); + const monitor = ctx.serialMonitor; ctx.setSerialMonitor(null); + if (monitor.isOpen) { + closes.push(new Promise(resolve => monitor.close(() => resolve()))); + } } + await Promise.all(closes); +} + +/** + * Starts the serial monitor on the given port. + * Closes any existing connections first. + */ +export async function startSerialMonitor(ctx: HandlerContext, portPath: string): Promise { + await closeAllSerial(ctx); + ctx.outputChannel.appendLine(`Opening serial monitor on ${portPath}`); const monitor = new SerialPort({ @@ -30,6 +60,8 @@ export function startSerialMonitor(ctx: HandlerContext, portPath: string): void monitor.on('error', err => { ctx.outputChannel.appendLine(`Serial error: ${err.message}`); + if (monitor.isOpen) { monitor.close(() => {}); } + ctx.setSerialMonitor(null); }); monitor.on('close', () => { @@ -62,21 +94,7 @@ export function stopSerialMonitorAndReset(ctx: HandlerContext, portPath: string) * This gives full bidirectional stdin/stdout β€” sys.stdin.read() works. */ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Promise { - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } - - if (ctx.mpRunProc) { - try { ctx.mpRunProc.kill(); } catch {} - ctx.setMpRunProc(null); - } - - if (ctx.runSerial && ctx.runSerial.isOpen) { - ctx.runSerial.close(); - ctx.setRunSerial(null); - } + await closeAllSerial(ctx); const { filename, port } = message; if (!filename || !port) { @@ -136,12 +154,15 @@ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Pr serial.on('close', () => { clearTimeout(promptTimeout); + const scriptFinishedNormally = isDone; finish(); ctx.outputChannel.appendLine('\n[run finished]'); - setTimeout(() => { - startSerialMonitor(ctx, port); - ctx.outputChannel.appendLine('Serial monitor reopened.'); - }, 250); + if (scriptFinishedNormally) { + setTimeout(() => { + startSerialMonitor(ctx, port); + ctx.outputChannel.appendLine('Serial monitor reopened.'); + }, 250); + } }); serial.on('error', (err: Error) => { @@ -215,8 +236,8 @@ export async function handleRunPythonFile(ctx: HandlerContext, message: any): Pr processRunningData(chunk); }); - // Interrupt any running code, then enter raw REPL mode - serial.write(Buffer.from([0x03, 0x03, 0x01])); // Ctrl-C, Ctrl-C, Ctrl-A + // Interrupt any running code, exit raw REPL if stuck in it, then enter raw REPL + serial.write(Buffer.from([0x03, 0x03, 0x02, 0x03, 0x03, 0x01])); // Ctrl-C, Ctrl-C, Ctrl-B, Ctrl-C, Ctrl-C, Ctrl-A }); } diff --git a/src/handlers/uploadHandler.ts b/src/handlers/uploadHandler.ts index 0e59b20..f233e2d 100644 --- a/src/handlers/uploadHandler.ts +++ b/src/handlers/uploadHandler.ts @@ -5,6 +5,7 @@ import * as os from 'os'; import { HandlerContext } from '../types'; import { execCommand, execMpremote, withRetry } from '../utils/execUtils'; import { uploadFileToDevice } from '../utils/uploadUtils'; +import { closeAllSerial } from './serialHandler'; /** * Tracks files downloaded from the device so Ctrl+S can upload back @@ -26,11 +27,7 @@ export function lookupDeviceFile(localPath: string): { port: string; devicePath: * Flushes any unsaved buffer changes to disk first so the device gets the latest content. */ export async function handleUploadPythonAsIs(ctx: HandlerContext, message: any): Promise { - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); const activeEditor = vscode.window.activeTextEditor; if (!activeEditor || activeEditor.document.languageId !== 'python') { @@ -74,11 +71,7 @@ export async function handleUploadPythonAsIs(ctx: HandlerContext, message: any): * Preserves directory structure relative to the selected folder root. */ export async function handleUploadPythonFromPc(ctx: HandlerContext, message: any): Promise { - if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { - ctx.outputChannel.appendLine('Stopping serial monitor before proceeding...'); - ctx.serialMonitor.close(); - ctx.setSerialMonitor(null); - } + await closeAllSerial(ctx); const choice = await vscode.window.showQuickPick( ['Single Python File', 'Folder of Python Files (including subfolders)'], @@ -180,6 +173,8 @@ export async function handleUploadPythonFromPc(ctx: HandlerContext, message: any * Registers the device path so Ctrl+S uploads back to the correct location. */ export async function handleOpenFileFromDevice(ctx: HandlerContext, message: any): Promise { + await closeAllSerial(ctx); + const { port, filename } = message; const tempDir = path.join(os.tmpdir(), 'esp-temp'); const localPath = path.join(tempDir, path.basename(filename));