diff --git a/.gitignore b/.gitignore index 5f9d142..1222609 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ out/ *.vsix # VS Code IDE settings -.vscode/ +# .vscode/ # Logs and metadata *.log diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3da2a4e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..32bb3f6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "vscode:prepublish", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": ["$tsc"] + } + ] +} diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..67f0f91 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,13 @@ +src/** +!src/panel/index.html +node_modules/** +tsconfig.json +.eslintrc* +.prettierrc* +.gitignore +.gitattributes +*.map +**/*.ts +!dist/** +package-lock.json +*.vsix diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a4ba34f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +## [0.3.0] + +### Added +- File tree view — device files and folders shown as collapsible tree (expanded by default) +- Folder support — recursive listing, recursive delete with confirmation dialog +- Folder structure preserved when uploading a folder from PC (subdirectories created on device automatically) +- Inline Open / Run / Del buttons per file row (visible on hover) +- Open File from Board and Delete Selected buttons work on tree selection +- Automatic retry (5 attempts) on all mpremote operations before alerting user + +### Fixed +- Open file from device failed for files inside subdirectories +- Sync `fs.lstatSync` / `fs.readdirSync` calls replaced with async equivalents +- GitHub API requests in module handler had no timeout (could hang indefinitely) +- Response stream not consumed when probing module deep path (socket leak) +- Module fetch unnecessarily re-fetched categories when valid cache existed + +## [0.2.0] + +### Added +- Serial monitor toggle (start/stop from panel) +- Module browser with category support, including flat-structure categories (e.g. Qwiic) +- GitHub authentication for higher API rate limits when fetching modules +- 24-hour module cache with refresh button and "last updated" timestamp +- Auto-select module when only one result in category +- Flash progress bar with percentage during ESP32 firmware write +- UF2 firmware support for RP2040/RP2350 boards +- Firmware list fetched from micropython.org (ESP32, RP2040, RP2350) +- Delete All Files button +- Open file from device into editor + +### Fixed +- Serial port locked during firmware flash (monitor now stopped before flashing) +- mpremote process not killed on stop (now kills entire process group) +- Wrong flash address for ESP32-C6/C3/C2/S3 (0x0 instead of 0x1000) +- esptool deprecated flag warnings +- Module category dropdown blank after module search +- execCommand error messages now include stderr output + +## [0.1.0] + +### Added +- Initial release +- ESP32 firmware flashing via esptool +- MicroPython file manager (list, upload, delete, run) +- Serial monitor +- Save Python file to device on Ctrl+S / Cmd+S diff --git a/README.md b/README.md index a48b4d3..aa84707 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,151 @@ # Soldered MicroPython Helper -⚠️ **Experimental Extension** -Use at your own risk. This extension is actively being developed. +A MicroPython-focused extension for Visual Studio Code designed for working with ESP and RP2-based boards. Flash firmware, upload scripts, monitor serial output, and fetch Soldered libraries — all from within the editor. -[VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=SolderedElectronics.soldered-micropython-helper&ssr=false) +> **Note:** This extension is actively developed. Report issues on [GitHub](https://github.com/SolderedElectronics/Soldered-MicroPython-Helper/issues). -A MicroPython-focused helper for working with ESP-based boards directly inside Visual Studio Code. -Flash firmware, upload scripts, monitor serial output, and fetch Soldered libraries — all in one place. +[Install from VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=SolderedElectronics.soldered-micropython-helper) --- -## ✨ What’s New +## Requirements -- **RP2040 & RP2350 support** — including UF2 flashing flows for supported boards. -- **Minor visual overhaul** — cleaner layout and controls. -- **Improved saving options** — optional *auto-save to both PC and device* on file save. - You can turn this on/off in **File → Preferences → Settings** and search for **“MicroPython Tools”**. -- **Smarter actions** — better automatic behavior when selecting ports and managing files. -- **Upload a whole folder** — pick a directory and upload all `.py` files (recursively, including subfolders) to the device in one go. +Before using this extension, install the required Python tools: ---- +```bash +pip install esptool mpremote +``` + +Both `esptool` and `mpremote` must be accessible on your system `PATH`. -## 🚀 Quick Setup (Required for Extension to Work) +### Serial port access -Run the following commands in your terminal: +**Linux:** If your board does not appear in the port list, run this once to grant your account access to serial ports: ```bash -# Install required Python tools -pip install esptool mpremote +sudo usermod -a -G dialout $USER ``` -Make sure: -- Your Python executables (`esptool` and `mpremote`) are in your system `PATH` -- You have permission to access serial ports (see below) +Then **log out and log back in**. After that, the extension can communicate with your board. ---- +**macOS:** No extra steps needed. If your board does not appear in the port list, you may need to install a USB-Serial driver for your board's chip: -## 🔍 How to Find and Install in VS Code +- [CP210x driver](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) — used by most ESP32 boards +- [CH340 driver](https://www.wch-ic.com/downloads/CH341SER_MAC_ZIP.html) — used by some cheaper boards -1. Open **Visual Studio Code** -2. Go to the **Extensions** tab (or press `Ctrl+Shift+X`) -3. Search for: `Soldered MicroPython Helper` -4. Click **Install** +After installing the driver, replug your board. -Or [install it directly from the VS Code Marketplace →](https://marketplace.visualstudio.com/items?itemName=SolderedElectronics.soldered-micropython-helper&ssr=false) +**Windows:** If no ports appear, try running VS Code as Administrator. --- -## 🔧 Features +## Features -- Flash firmware to boards (via `esptool.py`) -- Upload and delete `.py` files (via `mpremote`) +- Flash MicroPython firmware to ESP and RP2-based boards +- Upload, run, and delete `.py` files on the device +- Upload entire folders preserving directory structure +- Browse files and folders on the device in a tree view - Live serial output monitoring -- Fetch libraries and examples from Soldered's GitHub -- Auto-detect serial ports and show device files +- Open and edit files directly from the device +- Fetch Soldered libraries and examples from GitHub +- Auto-detect connected serial ports --- -## ⚙️ Full Setup Instructions +## Installation -### Requirements +1. Open Visual Studio Code. +2. Go to the Extensions panel (`Ctrl+Shift+X`). +3. Search for `Soldered MicroPython Helper`. +4. Click **Install**. -1. **Python 3.x** — [Download](https://www.python.org/downloads/) -2. **Visual Studio Code** — [Download](https://code.visualstudio.com/) +Alternatively, [install directly from the Marketplace](https://marketplace.visualstudio.com/items?itemName=SolderedElectronics.soldered-micropython-helper). -### Python packages (required globally or in your active environment): +--- -```bash -pip install esptool mpremote -``` +## Usage ---- +### Selecting a port -## ⚠️ Warning: Avoid Infinite Loops Without Delay + -When writing MicroPython code — especially when using `while True` loops — it's **critical to include a `sleep()` or other delay inside the loop**. This is standard practice to avoid common issues such as: +Select your board's serial port from the COM Port dropdown. Buttons requiring a connected device are disabled until a port is selected. -- **CPU overload** — the loop runs thousands of times per second without pause -- **Unreadable output** — serial prints become too fast to read -- **Poor device responsiveness** — the device may become unresponsive or glitchy -- **Unnecessary power consumption** +### Flashing firmware -### ✅ Recommended pattern: + -```python -from time import sleep +Select your board from the grouped dropdown and click **Flash**. The extension fetches the latest official MicroPython firmware automatically. -while True: - sleep(0.5) # Add delay between iterations - print("Doing something...") -``` +### Uploading files -Here's a real-world example from APDS9960 gesture detection: + -```python -while True: - sleep(0.5) - if apds.isGestureAvailable(): - motion = apds.readGesture() - print("Gesture={}".format(dirs.get(motion, "unknown"))) -``` +Upload the currently open file, any `.py` file from your PC, or an entire folder. When uploading a folder, the directory structure is preserved on the device — subdirectories are created automatically. Files can be run directly on the device and stopped at any time. -🧠 **Tip**: You can adjust the delay based on sensor type or application needs — just make sure *some* delay is present in every infinite loop. +### Managing files on the device ---- + -## 🛠 Other Notes +Click **List Files** to browse files and folders on the device in a tree view. Folders are expanded by default and can be collapsed. Hover over any item to reveal inline **Open**, **Run**, and **Del** buttons. Files opened from the device are downloaded to a temporary location and opened in the editor. Deleting a folder removes it and all its contents after confirmation. -- On Linux/macOS, you may need to add your user to the `dialout` or `uucp` group: - ```bash - sudo usermod -a -G dialout $USER - ``` - Then log out and back in. +### Installing Soldered modules -- On Windows, try running VS Code as Administrator if ports don’t show up. + ---- +Browse modules by category and select a module from the dropdown. Install the library, examples, or both with a single click. + +### Serial monitor -After completing the above, your VS Code extension should be able to access serial ports and run `esptool` and `mpremote` commands correctly. + + +Open the serial monitor to stream device output to the VS Code output panel. The monitor closes automatically when uploading or running files. --- -## 🧪 For Developers +## Notes on MicroPython code -If you'd like to contribute or modify this extension locally, follow these steps: +Always include a delay inside `while True` loops. Without one, the device may become unresponsive and output becomes unreadable. -### 1. Install dependencies -Make sure you have [Node.js](https://nodejs.org/), `npm`, and [Python 3.x](https://www.python.org/) installed. +```python +from time import sleep -To use the `serialport` Node.js library, native build tools must be installed: +while True: + sleep(0.5) + print("Running...") +``` -- **Windows:** - ```bash - npm install --global --production windows-build-tools - ``` +--- -- **macOS:** - ```bash - xcode-select --install - ``` +## Planned / TODO -- **Linux:** - ```bash - sudo apt-get install build-essential python3-dev - ``` +- **Drag & drop file upload** — drag `.py` files from your OS file explorer directly into the file tree to upload them to the device +- **Create folder on device** — add a new folder directly from the panel without uploading a file first +- **Port rescanning on disconnect** — automatically restart port scanning when a board is unplugged +- **Extension settings menu** +--- -Then, install `serialport`: +## Developer Setup -```bash -npm install serialport -``` +### Prerequisites -Python packages (required globally or in your active environment): +- [Node.js](https://nodejs.org/) and `npm` +- [Python 3.x](https://www.python.org/) +- Native build tools for the `serialport` package: + - **Windows:** `npm install --global --production windows-build-tools` + - **macOS:** `xcode-select --install` + - **Linux:** `sudo apt-get install build-essential python3-dev` -```bash -pip install esptool mpremote -``` +### Build -### 2. Build the extension ```bash +npm install npm run vscode:prepublish ``` -### 3. Launch in VS Code +### Run locally -- Open the project folder in VS Code. -- Press `F5` to open a new Extension Development Host window. -- The extension will load there and can be tested as if it were installed. +Open the project in VS Code and press `F5` to launch an Extension Development Host with the extension loaded. --- @@ -174,7 +153,7 @@ npm run vscode:prepublish soldered-logo -At Soldered, we design and manufacture a wide selection of electronic products to help you turn your ideas into acts and bring you one step closer to your final project. Our products are intented for makers and crafted in-house by our experienced team in Osijek, Croatia. We believe that sharing is a crucial element for improvement and innovation, and we work hard to stay connected with all our makers regardless of their skill or experience level. Therefore, all our products are open-source. Finally, we always have your back. If you face any problem concerning either your shopping experience or your electronics project, our team will help you deal with it, offering efficient customer service and cost-free technical support anytime. Some of those might be useful for you: +At Soldered, we design and manufacture a wide selection of electronic products to help you turn your ideas into acts and bring you one step closer to your final project. Our products are intended for makers and crafted in-house by our experienced team in Osijek, Croatia. We believe that sharing is a crucial element for improvement and innovation, and we work hard to stay connected with all our makers regardless of their skill or experience level. Therefore, all our products are open-source. Finally, we always have your back. If you face any problem concerning either your shopping experience or your electronics project, our team will help you deal with it, offering efficient customer service and cost-free technical support anytime. Some of those might be useful for you: - [Web Store](https://www.soldered.com/shop) - [Tutorials & Projects](https://soldered.com/learn) @@ -184,7 +163,7 @@ At Soldered, we design and manufacture a wide selection of electronic products t Soldered invests vast amounts of time into hardware & software for these products, which are all open-source. Please support future development by buying one of our products. -Check license details in the LICENSE file. Long story short, use these open-source files for any purpose you want to, as long as you apply the same open-source licence to it and disclose the original source. No warranty - all designs in this repository are distributed in the hope that they will be useful, but without any warranty. They are provided "AS IS", therefore without warranty of any kind, either expressed or implied. The entire quality and performance of what you do with the contents of this repository are your responsibility. In no event, Soldered (TAVU) will be liable for your damages, losses, including any general, special, incidental or consequential damage arising out of the use or inability to use the contents of this repository. +Check license details in the LICENSE file. Long story short, use these open-source files for any purpose you want to, as long as you apply the same open-source licence to it and disclose the original source. No warranty — all designs in this repository are distributed in the hope that they will be useful, but without any warranty. They are provided "AS IS", therefore without warranty of any kind, either expressed or implied. The entire quality and performance of what you do with the contents of this repository are your responsibility. In no event, Soldered (TAVU) will be liable for your damages, losses, including any general, special, incidental or consequential damage arising out of the use or inability to use the contents of this repository. ## Have fun! diff --git a/package-lock.json b/package-lock.json index cf16526..5b691dd 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", @@ -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 07c740f..56e8fd0 100644 --- a/package.json +++ b/package.json @@ -3,26 +3,36 @@ "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.3.0", "engines": { - "vscode": "^1.100.0" + "vscode": "^1.85.0" }, "icon": "mp.png", "main": "./dist/extension.js", + "repository": { + "type": "git", + "url": "https://github.com/SolderedElectronics/Soldered-MicroPython-Helper" + }, + "categories": [ + "Other", + "Programming Languages", + "Debuggers" + ], + "keywords": [ + "micropython", + "esp32", + "rp2040", + "embedded", + "serial", + "esptool", + "soldered" + ], "scripts": { "vscode:prepublish": "tsc -p ./" }, "activationEvents": [], "contributes": { "commands": [ - { - "command": "espFlasher.flashFirmware", - "title": "Flash Firmware (.bin)" - }, - { - "command": "espFlasher.uploadPython", - "title": "Upload Python File" - }, { "command": "mp.savePython", "title": "Save Python (PC or MicroPython device)" @@ -44,14 +54,14 @@ "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": true, - "description": "When uploading to device on save, also save the file locally to disk." - }, "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." }, @@ -59,6 +69,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')." } } }, @@ -90,7 +105,6 @@ }, "dependencies": { "cheerio": "^1.1.0", - "fuse.js": "^7.1.0", "serialport": "^13.0.0" } } diff --git a/src/EspFlasherProvider.ts b/src/EspFlasherProvider.ts new file mode 100644 index 0000000..815d91a --- /dev/null +++ b/src/EspFlasherProvider.ts @@ -0,0 +1,268 @@ +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, 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 { execMpremote } from './utils/execUtils'; + +const IGNORED_PORT_PATTERNS = ['debug-console', 'Bluetooth-Incoming-Port']; + +function filterPorts(ports: { path: string }[]): string[] { + return ports + .map(p => p.path) + .filter(p => !IGNORED_PORT_PATTERNS.some(pattern => p.includes(pattern))); +} + +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; + + 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; }, + get runSerial() { return self.runSerial; }, + setRunSerial: (s: SerialPort | null) => { self.runSerial = s; }, + 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; + } + + /** + * 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. + */ + private async refreshState(): Promise { + const ports = filterPorts(await SerialPort.list()); + this._view?.webview.postMessage({ + command: 'populatePorts', + ports, + }); + } + + /** + * 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(); + + webviewView.webview.postMessage({ + command: 'populatePorts', + ports: filterPorts(await SerialPort.list()), + }); + + 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', 'getAllModules', '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 'listFiles': + await 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); + 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': { + this._view?.webview.postMessage({ + command: 'populatePorts', + ports: filterPorts(await SerialPort.list()), + }); + 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 'getAllModules': + await handleGetAllModules(ctx, message); + break; + + case 'deleteFile': + await handleDeleteFile(ctx, message); + break; + + case 'deleteAllFiles': + await handleDeleteAllFiles(ctx, message); + break; + + case 'checkMicroPython': + 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 }); + }) + .catch(() => { + this._view?.webview.postMessage({ command: 'micropythonStatus', installed: false }); + }); + break; + + case 'stopSerialMonitor': + if (ctx.serialMonitor && ctx.serialMonitor.isOpen) { + ctx.serialMonitor.close(); + ctx.setSerialMonitor(null); + } + 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; + + 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..2791c7b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,1381 +1,123 @@ // 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 { EspFlasherViewProvider } from './EspFlasherProvider'; +import { uploadFileToDevice } from './utils/uploadUtils'; +import { lookupDeviceFile } from './handlers/uploadHandler'; -// Fuse.js provides fuzzy searching to find the best matching firmware options based on user input -import Fuse from 'fuse.js'; +let activeProvider: EspFlasherViewProvider | undefined; -// 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); + activeProvider = provider; + 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. - * - If "save to device on save" is enabled, optionally also save locally. - * - Otherwise, respect "savePromptMode" (and prompt if it's "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 } - ], - { placeHolder: 'Where do you want to save this .py?' } - ); - if (!pick) throw new Error('cancelled'); // user dismissed the picker - 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. + * + * 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); + out.appendLine('mp.savePython invoked'); - // 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'); + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'python') { 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); - 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(`❌ 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. - */ - 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'); - } - - // 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.'); - return false; - } - doc = ed.document; - return true; - }; - - /** - * 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. - */ - const uploadToDevice = async () => { - const port = await pickPort(); - 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 }); + 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'); - const fname = saveAsMain ? 'main.py' : (path.basename(doc.fileName || 'code.py') || 'code.py'); - const tmpPath = path.join(tmpDir, fname); + let doc = editor.document; - await fs.promises.writeFile(tmpPath, doc.getText(), 'utf8'); - out.appendLine(`Uploading buffer → temp: ${tmpPath} → device:${saveAsMain ? 'main.py' : 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}`); - reject(new Error(stderr || String(err))); - } else { - 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 - 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}`); - - 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}`); - reject(new Error(stderr || String(err))); - } else { - 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 - }; - - // Execute the chosen flow - 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(`❌ 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); + // 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 { - // 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; + await vscode.commands.executeCommand('workbench.action.files.save'); + doc = vscode.window.activeTextEditor?.document || doc; } + out.appendLine(`Saved to disk: ${doc.fileName}`); - 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; + // 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'); + } + + if (!shouldUpload) { + vscode.window.setStatusBarMessage('Saved to PC', 1500); + return; } - // ---- Start serial monitor manually ---- - case 'startSerialMonitor': { - const { port } = message; - if (!port) { - vscode.window.showErrorMessage('Please select a port.'); - return; - } - this.startSerialMonitor(port); - break; + // 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; } - // ---- 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}"`); - } + // 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); + // Step 5: Upload + try { + await provider.releasePort(); 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)}`); - } - } + { location: vscode.ProgressLocation.Notification, title: 'Uploading to device...', cancellable: false }, + async () => { await uploadFileToDevice(doc.fileName, devicePath, port, out); } ); - break; + 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}`); } - - // ---- 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; +export async function deactivate() { + await activeProvider?.releasePort(); } -} \ No newline at end of file diff --git a/src/handlers/fileHandler.ts b/src/handlers/fileHandler.ts new file mode 100644 index 0000000..d8ffcb3 --- /dev/null +++ b/src/handlers/fileHandler.ts @@ -0,0 +1,132 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +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. + * Each node: { name, type: 'file'|'dir', path, children? } + */ +export async function handleListFiles(ctx: HandlerContext, message: any): Promise { + const { port } = message; + + const script = `import os, json +def tree(p): + r = [] + try: + entries = sorted(os.listdir(p)) + except: + return r + for n in entries: + fp = p.rstrip('/') + '/' + n + try: + os.listdir(fp) + r.append({'name': n, 'type': 'dir', 'path': fp, 'children': tree(fp)}) + except: + r.append({'name': n, 'type': 'file', 'path': fp}) + return r +print(json.dumps(tree('/'))) +`; + + await closeAllSerial(ctx); + + const tempPath = path.join(os.tmpdir(), '__list_files__.py'); + + try { + await fs.promises.writeFile(tempPath, script, 'utf8'); + const stdout = await withRetry( + () => execMpremote(`mpremote connect ${port} run "${tempPath}"`), + 5, 500, 'listFiles', ctx.outputChannel + ); + const match = stdout.match(/\[[\s\S]*\]/); + const files = match ? JSON.parse(match[0]) : []; + ctx.postMessage({ command: 'displayFiles', files }); + } catch (err: any) { + ctx.outputChannel.appendLine(`[WARN] Failed to list files: ${err.message}`); + ctx.postMessage({ command: 'displayFiles', files: [] }); + } +} + +/** + * Deletes a file or folder (recursively) from the connected MicroPython device. + * Confirms before deleting directories. + */ +export async function handleDeleteFile(ctx: HandlerContext, message: any): Promise { + const { port, filename, type } = message; + + if (type === 'dir') { + const confirm = await vscode.window.showWarningMessage( + `Delete folder "${filename}" and all its contents?`, + { modal: true }, + 'Delete' + ); + if (confirm !== 'Delete') return; + } + + await closeAllSerial(ctx); + + const b64name = Buffer.from(filename).toString('base64'); + const script = `import os, ubinascii +def rm(p): + try: + os.remove(p) + except OSError: + for f in os.listdir(p): + rm(p + '/' + f) + os.rmdir(p) +rm(ubinascii.a2b_base64('${b64name}').decode()) +`; + + const tempPath = path.join(os.tmpdir(), '__delete__.py'); + + try { + await fs.promises.writeFile(tempPath, script, 'utf8'); + await execMpremote(`mpremote connect ${port} run "${tempPath}"`); + vscode.window.showInformationMessage(`Deleted ${filename} successfully.`); + ctx.postMessage({ command: 'triggerListFiles', port }); + } catch (err: any) { + ctx.outputChannel.appendLine(`[WARN] Failed to delete: ${err.message}`); + vscode.window.showErrorMessage(`Failed to delete: ${err.message}`); + } +} + +/** + * Deletes all files and folders on the device except boot.py and main.py. + * Uses a temp Python script run via mpremote to handle recursive deletion. + */ +export async function handleDeleteAllFiles(ctx: HandlerContext, message: any): Promise { + const { port } = message; + + await closeAllSerial(ctx); + + const script = ` +import os +def rm(p): + try: + os.remove(p) + except OSError: + for f in os.listdir(p): + rm(p + '/' + f) + os.rmdir(p) +for f in os.listdir(): + if f not in ('boot.py', 'main.py'): + rm(f) +print('done') +`; + + const tempPath = path.join(os.tmpdir(), '__delete_all__.py'); + + try { + await fs.promises.writeFile(tempPath, script, 'utf8'); + ctx.outputChannel.appendLine('Deleting all files from device...'); + await execMpremote(`mpremote connect ${port} run "${tempPath}"`); + vscode.window.showInformationMessage('All files deleted (boot.py and main.py kept).'); + ctx.postMessage({ command: 'triggerListFiles', port }); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to delete files: ${err.message}`); + ctx.outputChannel.appendLine(`[ERROR] Delete all failed: ${err.message}`); + } +} diff --git a/src/handlers/flashHandler.ts b/src/handlers/flashHandler.ts new file mode 100644 index 0000000..d81b43e --- /dev/null +++ b/src/handlers/flashHandler.ts @@ -0,0 +1,258 @@ +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, 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. + * ESP32-C2/C3/C6 and S3 use 0x0; original ESP32 and S2 use 0x1000. + */ +function getFlashAddress(firmwareName: string): string { + return /esp32[_-].*(c2|c3|c6|s3)/i.test(firmwareName) ? '0x0' : '0x1000'; +} + +/** + * 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; +} + +/** + * Spawns esptool and streams its output, parsing progress percentage and + * sending flashProgress messages to the webview as writing proceeds. + */ +function streamFlashWithProgress(command: string, ctx: HandlerContext): Promise { + return new Promise((resolve, reject) => { + ctx.outputChannel.appendLine(`Executing: ${command}`); + const child = spawn(command, [], { shell: true }); + + const handleChunk = (data: string) => { + ctx.outputChannel.append(data); + for (const segment of data.split(/[\r\n]+/)) { + const pctMatch = segment.match(/(\d+(?:\.\d+)?)%/); + if (pctMatch) { + const pct = parseFloat(pctMatch[1]); + ctx.postMessage({ command: 'flashProgress', percent: Math.round(pct), label: `Writing... ${pct}%` }); + continue; + } + if (/erasing flash/i.test(segment)) { + ctx.postMessage({ command: 'flashProgress', percent: 0, label: 'Erasing flash...' }); + } + if (/hash of data verified/i.test(segment)) { + ctx.postMessage({ command: 'flashProgress', percent: 100, label: 'Verifying...' }); + } + } + }; + + child.stdout.on('data', (d: Buffer) => handleChunk(d.toString())); + child.stderr.on('data', (d: Buffer) => handleChunk(d.toString())); + child.on('close', (code) => { code === 0 ? resolve() : reject(new Error(`esptool exited with code ${code}`)); }); + child.on('error', reject); + }); +} + +/** + * 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 { + await closeAllSerial(ctx); + mpremoteQueue.abort(); + await new Promise(r => setTimeout(r, 500)); // let OS release the port + + 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 flashAddr = getFlashAddress(firmwareName); + const command = `${esptoolPath} --port ${port} --baud 115200 write-flash --flash-mode keep --flash-size keep --erase-all ${flashAddr} "${tmpPath}"`; + + ctx.postMessage({ command: 'flashStatusUpdate', text: 'start' }); + ctx.postMessage({ command: 'flashProgress', percent: 0, label: 'Starting...' }); + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Flashing firmware...', cancellable: false }, + async () => { + try { + await streamFlashWithProgress(command, ctx); + vscode.window.showInformationMessage('Flash successful!'); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'done' }); + // wait for board to finish booting before listing files + await new Promise(r => setTimeout(r, 3000)); + ctx.postMessage({ command: 'triggerListFiles', port }); + } catch (err: any) { + vscode.window.showErrorMessage(`Flash failed: ${err.message}`); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'error' }); + } + } + ); + } +} + +/** + * Handles flashing a locally selected .bin firmware file. + */ +export async function handleFlashFirmware(ctx: HandlerContext, message: any): Promise { + await closeAllSerial(ctx); + mpremoteQueue.abort(); + await new Promise(r => setTimeout(r, 500)); // let OS release the port + + 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 flashAddr = getFlashAddress(path.basename(firmwarePath)); + const cmd = `${esptoolPath} --port ${message.port} --baud 115200 write-flash --flash-mode keep --flash-size keep --erase-all ${flashAddr} "${firmwarePath}"`; + + ctx.postMessage({ command: 'flashStatusUpdate', text: 'start' }); + ctx.postMessage({ command: 'flashProgress', percent: 0, label: 'Starting...' }); + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Flashing firmware...', cancellable: false }, + async () => { + try { + await streamFlashWithProgress(cmd, ctx); + vscode.window.showInformationMessage('Firmware flashed successfully!'); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'done' }); + await new Promise(r => setTimeout(r, 3000)); + ctx.postMessage({ command: 'triggerListFiles', port: message.port }); + } catch (err: any) { + vscode.window.showErrorMessage(`Firmware flashing failed: ${err.message}`); + ctx.postMessage({ command: 'flashStatusUpdate', text: 'error' }); + } + } + ); +} diff --git a/src/handlers/moduleHandler.ts b/src/handlers/moduleHandler.ts new file mode 100644 index 0000000..5b28963 --- /dev/null +++ b/src/handlers/moduleHandler.ts @@ -0,0 +1,376 @@ +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'; +import { closeAllSerial } from './serialHandler'; + +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']; +const CACHE_KEY = 'mp.modulesCache'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +// GitHub token cache — undefined = not yet checked, null = not available +let cachedGithubToken: string | null | undefined = undefined; + +async function getGithubToken(): Promise { + if (cachedGithubToken) return cachedGithubToken; // only cache real tokens; null/undefined re-checks each time + try { + const session = await vscode.authentication.getSession('github', [], { createIfNone: false }); + const token = session?.accessToken ?? null; + if (token) cachedGithubToken = token; + return token; + } catch { + return null; + } +} + +async function githubHeaders(): Promise> { + const token = await getGithubToken(); + const headers: Record = { 'User-Agent': 'vscode-extension' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + return headers; +} + +interface ModulesCache { + timestamp: number; + categories: string[]; + modules: { [category: string]: string[] }; +} + +function getCachedModules(ctx: HandlerContext): ModulesCache | null { + const cached = ctx.extensionContext.globalState.get(CACHE_KEY); + if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) { + return cached; + } + return null; +} + +async function setCachedModules(ctx: HandlerContext, categories: string[], modules: { [category: string]: string[] }): Promise { + const cache: ModulesCache = { timestamp: Date.now(), categories, modules }; + await ctx.extensionContext.globalState.update(CACHE_KEY, cache); + return cache; +} + +/** + * Fetches all top-level directory names from the repo root. + * Falls back to hardcoded list if the request fails. + */ +async function fetchCategories(): Promise { + const headers = await githubHeaders(); + return new Promise((resolve) => { + https.get(REPO_ROOT, { headers }, 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. + * Uses cache if available. + */ +export async function handleGetCategories(ctx: HandlerContext): Promise { + const cached = getCachedModules(ctx); + if (cached) { + ctx.postMessage({ command: 'setCategories', categories: cached.categories }); + return; + } + const categories = await fetchCategories(); + ctx.postMessage({ command: 'setCategories', categories }); +} + +/** + * Fetches all modules across all categories in parallel. + * Returns a map of { [category]: string[] } to the webview. + * Uses globalState cache with 24hr TTL. Pass force: true to bypass cache. + */ +export async function handleGetAllModules(ctx: HandlerContext, message: any = {}): Promise { + const { force } = message; + + if (!force) { + const cached = getCachedModules(ctx); + if (cached) { + ctx.postMessage({ command: 'setCategories', categories: cached.categories }); + ctx.postMessage({ command: 'setAllModules', modules: cached.modules, cachedAt: cached.timestamp }); + return; + } + } + + ctx.postMessage({ command: 'setAllModulesLoading' }); + + const categories = await fetchCategories(); + const headers = await githubHeaders(); + + const results = await Promise.all( + categories.map(category => + new Promise<{ category: string; modules: string[] }>(resolve => { + const apiUrl = `${REPO_ROOT}/${category}`; + https.get(apiUrl, { headers }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const items = JSON.parse(data); + let modules = items.filter((f: any) => f.type === 'dir').map((f: any) => f.name); + // flat-structure category (e.g. Qwiic): no subdirs, .py files directly inside + if (modules.length === 0) { + modules = items + .filter((f: any) => f.type === 'file' && f.name.endsWith('.py')) + .map((f: any) => f.name.replace(/\.py$/, '')); + } + resolve({ category, modules }); + } catch { + resolve({ category, modules: [] }); + } + }); + }).on('error', () => resolve({ category, modules: [] })); + }) + ) + ); + + const moduleMap: { [category: string]: string[] } = {}; + results.forEach(({ category, modules }) => { + if (modules.length > 0) moduleMap[category] = modules; + }); + + const cache = await setCachedModules(ctx, categories, moduleMap); + ctx.postMessage({ command: 'setCategories', categories }); + ctx.postMessage({ command: 'setAllModules', modules: moduleMap, cachedAt: cache.timestamp }); +} + +/** + * Returns all module folders inside a given category. + * Uses cache if available, otherwise fetches from GitHub. + */ +export async function handleGetModulesForCategory(ctx: HandlerContext, message: any): Promise { + const { category } = message; + if (!category) { + ctx.postMessage({ command: 'setModulesForCategory', modules: [] }); + return; + } + + // Use cache if available + const cached = getCachedModules(ctx); + if (cached && cached.modules[category]) { + ctx.postMessage({ command: 'setModulesForCategory', modules: cached.modules[category] }); + return; + } + + const apiUrl = `${REPO_ROOT}/${category}`; + const headers = await githubHeaders(); + return new Promise((resolve) => { + https.get(apiUrl, { headers }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const items = JSON.parse(data); + let modules = items.filter((f: any) => f.type === 'dir').map((f: any) => f.name); + if (modules.length === 0) { + modules = items + .filter((f: any) => f.type === 'file' && f.name.endsWith('.py')) + .map((f: any) => f.name.replace(/\.py$/, '')); + } + 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 all .py files from a GitHub API URL and uploads them to the device. + */ +async function uploadDirectoryFiles(url: string, port: string, ctx: HandlerContext): Promise { + const headers = await githubHeaders(); + const files: any[] = await new Promise((resolve, reject) => { + const req = https.get(url, { headers }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { reject(e); } + }); + }).on('error', reject); + req.setTimeout(10000, () => req.destroy(new Error('Request timed out'))); + }); + + const pyFiles = files.filter((f: any) => f.name.endsWith('.py')); + if (pyFiles.length === 0) { + vscode.window.showWarningMessage(`No .py files found.`); + return; + } + + for (const file of pyFiles) { + const uploadName = file.name.replace(/-/g, '_'); + const tempPath = path.join(os.tmpdir(), uploadName); + await downloadFile(file.download_url, tempPath); + ctx.outputChannel.appendLine(`Uploading ${uploadName}`); + await execCommand(`mpremote connect ${port} fs cp "${tempPath}" :"${uploadName}"`, ctx.outputChannel); + } +} + +/** + * Fetches example files, shows a multi-select QuickPick, uploads chosen files. + */ +async function uploadExamplesWithPicker(url: string, port: string, sensor: string, ctx: HandlerContext): Promise { + const headers = await githubHeaders(); + const files: any[] = await new Promise((resolve, reject) => { + const req = https.get(url, { headers }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { reject(e); } + }); + }).on('error', reject); + req.setTimeout(10000, () => req.destroy(new Error('Request timed out'))); + }); + + const pyFiles = files.filter((f: any) => f.name.endsWith('.py')); + if (pyFiles.length === 0) { + vscode.window.showWarningMessage(`No example files found for "${sensor}".`); + return; + } + + const items = pyFiles.map((f: any) => ({ label: f.name, picked: true, file: f })); + const selected = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: `Select examples to download for "${sensor}" (all pre-selected)`, + title: 'Download Examples', + }); + + if (!selected || selected.length === 0) return; + + for (const item of selected) { + const uploadName = item.file.name.replace(/-/g, '_'); + const tempPath = path.join(os.tmpdir(), uploadName); + await downloadFile(item.file.download_url, tempPath); + ctx.outputChannel.appendLine(`Uploading ${uploadName}`); + await execCommand(`mpremote connect ${port} fs cp "${tempPath}" :"${uploadName}"`, ctx.outputChannel); + } +} + +/** + * 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; + + await closeAllSerial(ctx); + + if (!sensor || !port) { + vscode.window.showErrorMessage('Module name and port are required.'); + return; + } + + const cached = getCachedModules(ctx); + const categories = cached?.categories ?? await fetchCategories(); + const headers = await githubHeaders(); + let baseUrl: string | undefined; + let flatDownloadUrl: string | undefined; // set only for flat-structure modules (e.g. Qwiic) + const searchCategories: string[] = []; + if (cached) { + for (const [cat, mods] of Object.entries(cached.modules)) { + if (mods.includes(sensor)) { searchCategories.unshift(cat); break; } + } + } + // append remaining categories as fallback + for (const cat of categories) { + if (!searchCategories.includes(cat)) searchCategories.push(cat); + } + + for (const category of searchCategories) { + // Try standard deep path: category/module/module/ + const deepUrl = `${REPO_ROOT}/${category}/${sensor}/${sensor}`; + const deepStatus: number | undefined = await new Promise((resolve) => { + https.get(deepUrl, { headers }, res => { + res.resume(); // consume and discard body to free the socket + resolve(res.statusCode); + }).on('error', () => resolve(undefined)); + }); + if (deepStatus === 200) { + baseUrl = deepUrl; + break; + } + + // Try flat path: category/module.py (e.g. Qwiic/qwiic.py) + const categoryListData: string | undefined = await new Promise((resolve) => { + https.get(`${REPO_ROOT}/${category}`, { headers }, res => { + let data = ''; + res.on('data', (chunk: string) => data += chunk); + res.on('end', () => resolve(data)); + }).on('error', () => resolve(undefined)); + }); + if (categoryListData) { + try { + const items = JSON.parse(categoryListData); + const match = items.find((f: any) => + f.type === 'file' && f.name.toLowerCase() === `${sensor.toLowerCase()}.py` + ); + if (match) { + baseUrl = `${REPO_ROOT}/${category}`; + flatDownloadUrl = match.download_url; + break; + } + } catch {} + } + } + + if (!baseUrl) { + vscode.window.showErrorMessage(`Could not find module "${sensor}" in any category.`); + return; + } + + try { + if (mode === 'library' || mode === 'all') { + if (flatDownloadUrl) { + // flat module: single .py file directly in category folder + const uploadName = `${sensor}.py`.replace(/-/g, '_'); + const tempPath = path.join(os.tmpdir(), uploadName); + await downloadFile(flatDownloadUrl, tempPath); + ctx.outputChannel.appendLine(`Uploading ${uploadName}`); + await execCommand(`mpremote connect ${port} fs cp "${tempPath}" :"${uploadName}"`, ctx.outputChannel); + } else { + await uploadDirectoryFiles(baseUrl, port, ctx); + } + } + if (mode === 'examples' || mode === 'all') { + if (!flatDownloadUrl) { + await uploadExamplesWithPicker(`${baseUrl}/Examples`, port, sensor, ctx); + } + // flat modules have no examples — skip silently + } + + vscode.window.showInformationMessage(`Downloaded ${mode} files for "${sensor}"`); + ctx.postMessage({ command: 'triggerListFiles', port }); + ctx.postMessage({ command: 'moduleFetchStatus', mode, status: 'done' }); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch module: ${err.message}`); + ctx.outputChannel.appendLine(`[ERROR] ${err.message}`); + ctx.postMessage({ command: 'moduleFetchStatus', mode, status: 'error' }); + } +} diff --git a/src/handlers/serialHandler.ts b/src/handlers/serialHandler.ts new file mode 100644 index 0000000..35a0111 --- /dev/null +++ b/src/handlers/serialHandler.ts @@ -0,0 +1,323 @@ +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 { HandlerContext } from '../types'; +import { execCommand, execMpremote, execWithTimeout, killProc, withRetry } from '../utils/execUtils'; + +/** + * 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 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) { + 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({ + 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}`); + if (monitor.isOpen) { monitor.close(() => {}); } + ctx.setSerialMonitor(null); + }); + + monitor.on('close', () => { + ctx.outputChannel.appendLine('Serial monitor closed.'); + ctx.postMessage({ command: 'serialMonitorStatus', active: false }); + }); + + ctx.setSerialMonitor(monitor); + ctx.postMessage({ command: 'serialMonitorStatus', active: true }); +} + +/** + * 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); + } + + execMpremote(`mpremote connect ${portPath} soft-reset`) + .then(() => ctx.outputChannel.appendLine('Device reset successfully.')) + .catch(err => ctx.outputChannel.appendLine(`[ERROR] Error resetting device: ${err.message}`)); +} + +/** + * 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 { + await closeAllSerial(ctx); + + 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 withRetry(() => execCommand(downloadCmd, ctx.outputChannel), 5, 500, 'runFile-download', ctx.outputChannel); + } + ); + + const scriptContent = fs.readFileSync(tempPath); + + ctx.outputChannel.appendLine(`Running ${filename}`); + ctx.outputChannel.show(true); + + const serial = new SerialPort({ path: port, baudRate: 115200, autoOpen: false }); + + 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; + } + + 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); + const scriptFinishedNormally = isDone; + finish(); + ctx.outputChannel.appendLine('\n[run finished]'); + if (scriptFinishedNormally) { + 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; + + 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, 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 + }); +} + +/** + * 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) 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); + } + + // 3) 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)); + + // 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 }); + 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)}`); + } + } + } + + // 5) 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..f233e2d --- /dev/null +++ b/src/handlers/uploadHandler.ts @@ -0,0 +1,198 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +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'; +import { closeAllSerial } from './serialHandler'; + +/** + * 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 }. + */ +const deviceFileRegistry = new Map(); + +export function registerDeviceFile(localPath: string, port: string, devicePath: string): void { + deviceFileRegistry.set(localPath, { port, devicePath }); +} + +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 { + await closeAllSerial(ctx); + + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor || activeEditor.document.languageId !== 'python') { + vscode.window.showErrorMessage('No active Python file to upload.'); + return; + } + + 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; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Uploading ${fileName} to device...`, cancellable: false }, + async () => { + try { + 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}`); + } + } + ); +} + +/** + * 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 { + await closeAllSerial(ctx); + + 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 = await fs.promises.lstat(selectedPath); + const { port } = message; + + interface UploadFile { localPath: string; devicePath: string; } + const uploadFiles: UploadFile[] = []; + const mkdirPaths: string[] = []; + + if (stats.isDirectory()) { + const walk = async (dir: string): Promise => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.py')) { + const relPath = path.relative(selectedPath, fullPath).replace(/\\/g, '/'); + uploadFiles.push({ localPath: fullPath, devicePath: '/' + relPath }); + } + } + }; + await walk(selectedPath); + + if (uploadFiles.length === 0) { + vscode.window.showErrorMessage('Selected folder does not contain any .py files.'); + return; + } + + // Collect unique device-side parent dirs, shallowest first + const dirsSet = new Set(); + uploadFiles.forEach(({ devicePath }) => { + const parts = devicePath.split('/').filter(Boolean); + for (let i = 1; i < parts.length; i++) { + dirsSet.add('/' + parts.slice(0, i).join('/')); + } + }); + mkdirPaths.push( + ...Array.from(dirsSet).sort((a, b) => a.split('/').length - b.split('/').length) + ); + } else { + uploadFiles.push({ localPath: selectedPath, devicePath: '/' + path.basename(selectedPath) }); + } + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Uploading Python file(s)...', cancellable: false }, + async () => { + try { + // Create directories on device (ignore errors — dir may already exist) + for (const dir of mkdirPaths) { + const b64 = Buffer.from(dir).toString('base64'); + await execCommand( + `mpremote connect ${port} exec "import os,ubinascii; os.mkdir(ubinascii.a2b_base64('${b64}').decode())"`, + ctx.outputChannel + ).catch(() => {}); + } + for (const { localPath, devicePath } of uploadFiles) { + await uploadFileToDevice(localPath, devicePath, port, ctx.outputChannel); + } + vscode.window.showInformationMessage('All .py files 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}`); + } + } + ); +} + +/** + * 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 { + await closeAllSerial(ctx); + + const { port, filename } = message; + const tempDir = path.join(os.tmpdir(), 'esp-temp'); + const localPath = path.join(tempDir, path.basename(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...`); + + 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.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to download file: ${err.message}`); + } +} diff --git a/src/panel/index.html b/src/panel/index.html index 9479252..a23d3f6 100644 --- a/src/panel/index.html +++ b/src/panel/index.html @@ -6,7 +6,7 @@ @@ -259,14 +364,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 +381,53 @@

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 + Run & Manage Files

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

No files found — click List Files.

+
+
@@ -457,14 +529,29 @@

Fetch Soldered MicroPython Module

- - + + + + + +
+ +
    +
    - - +
    or browse by category
    -
    + + + + + +
    - - -
    + +
    @@ -495,6 +581,171 @@

    // 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.className = 'tree-file-indicator'; + 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); + if (diffMin < 1) return 'last updated just now'; + if (diffMin < 60) return `last updated ${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `last updated ${diffHr}h ago`; + return `last updated ${Math.floor(diffHr / 24)}d ago`; +} + function saveToggleState() { const state = {}; document.querySelectorAll('.toggleable').forEach(section => { @@ -522,12 +773,27 @@

    } // === Init events === +let portScanInterval = null; + +function startPortScanning() { + if (portScanInterval) return; + portScanInterval = setInterval(() => vscode.postMessage({ command: 'getPorts' }), 4000); +} + +function stopPortScanning() { + if (portScanInterval) { + clearInterval(portScanInterval); + portScanInterval = null; + } +} + 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 }); + restoreState(); + vscode.postMessage({ command: 'getFirmwareOptions' }); + vscode.postMessage({ command: 'getCategories' }); + vscode.postMessage({ command: 'getAllModules' }); + startPortScanning(); }); window.addEventListener('focus', () => { @@ -535,15 +801,39 @@

    }); // 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; - if (!port) return alert('Please select a COM port.'); - vscode.postMessage({ command: 'startSerialMonitor', port }); + if (!port) return; + if (serialMonitorActive) { + vscode.postMessage({ command: 'stopSerialMonitor', port }); + } else { + vscode.postMessage({ command: 'startSerialMonitor', port }); + } +}); + +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 alert('Please select a COM port.'); + if (!port) return; vscode.postMessage({ command: 'stopRunningCode', port }); }); @@ -553,12 +843,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', () => { @@ -568,7 +852,6 @@

    // Validate inputs before proceeding if (!firmwareUrl || !port) { - alert('Please select firmware and port before flashing.'); return; } @@ -584,17 +867,46 @@

    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 = ''; + + // Always prepend a placeholder + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.disabled = true; + placeholder.selected = true; + placeholder.textContent = '-- Select board --'; + select.appendChild(placeholder); + + 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