diff --git a/package-lock.json b/package-lock.json index 7bf4b06..7632fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "fs-extra": "^11.2.0", "lodash": "^4.17.21", "rimraf": "^6.0.1", + "undici": "^6.19.8", "uuid": "^10.0.0", "which": "^4.0.0" }, @@ -4356,6 +4357,15 @@ } } }, + "node_modules/undici": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", diff --git a/package.json b/package.json index ae5b563..bf5f644 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,11 @@ "command": "micropico.garbageCollect", "title": "Trigger garbage collection", "category": "MicroPico" + }, + { + "command": "micropico.flashPico", + "title": "Flash Pico in BOOTSEL mode", + "category": "MicroPico" } ], "menus": { @@ -651,6 +656,7 @@ "fs-extra": "^11.2.0", "lodash": "^4.17.21", "rimraf": "^6.0.1", + "undici": "^6.19.8", "uuid": "^10.0.0", "which": "^4.0.0" }, diff --git a/rollup.config.mjs b/rollup.config.mjs index a7dd145..39e911b 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -13,7 +13,7 @@ export default { file: 'dist/extension.cjs', format: 'cjs', sourcemap: true, - exports: 'named', + exports: 'named' }, external: [ 'vscode' diff --git a/src/activator.mts b/src/activator.mts index 3433c0b..ed94107 100644 --- a/src/activator.mts +++ b/src/activator.mts @@ -37,6 +37,7 @@ import { type ActiveEnvironmentPathChangeEvent, PythonExtension, } from "@vscode/python-extension"; +import { flashPicoInteractively } from "./flash.mjs"; /*const pkg: {} | undefined = vscode.extensions.getExtension("paulober.pico-w-go") ?.packageJSON as object;*/ @@ -56,6 +57,7 @@ export default class Activator { private autoConnectTimer?: NodeJS.Timeout; private comDevice?: string; + private noCheckForUSBMSDs = false; constructor() { this.logger = new Logger("Activator"); @@ -1588,6 +1590,32 @@ export default class Activator { ); } ); + context.subscriptions.push(disposable); + + disposable = vscode.commands.registerCommand( + commandPrefix + "flashPico", + async () => { + const result = await vscode.window.showInformationMessage( + "This will flash the latest MicroPython firmware to your Pico. " + + "Do you want to continue?", + { + modal: true, + detail: + "Note: Only Raspberry Pi Pico boards are supported. " + + "Make sure it is connected and in BOOTSEL mode. " + + "You can verify this by checking if a drive " + + "labeled RPI-RP2 or RP2350 is mounted.", + } + ); + + if (result === undefined) { + return; + } + + await flashPicoInteractively(true); + } + ); + context.subscriptions.push(disposable); const packagesWebviewProvider = new PackagesWebviewProvider( context.extensionUri @@ -1739,6 +1767,12 @@ export default class Activator { PicoMpyCom.getSerialPorts() .then(async ports => { if (ports.length === 0) { + if (!this.noCheckForUSBMSDs) { + // must be reset after checkForUSBMSDs if it want to continue + this.noCheckForUSBMSDs = true; + await this.checkForUSBMSDs(); + } + return; } @@ -1785,6 +1819,11 @@ export default class Activator { this.autoConnectTimer = setInterval(onAutoConnect, 1500); } + private async checkForUSBMSDs(): Promise { + const result = await flashPicoInteractively(); + this.noCheckForUSBMSDs = result; + } + private showNoActivePythonError(): void { vscode.window .showWarningMessage( diff --git a/src/downloadFirmware.mts b/src/downloadFirmware.mts new file mode 100644 index 0000000..7817fc6 --- /dev/null +++ b/src/downloadFirmware.mts @@ -0,0 +1,120 @@ +import { tmpdir } from "os"; +import { basename, join } from "path"; +import { createWriteStream } from "fs"; +import { request } from "undici"; + +export enum SupportedFirmwareTypes { + pico, + picow, + pico2, +} + +function firmwareTypeToDownloadURL( + firmwareType: SupportedFirmwareTypes +): string { + switch (firmwareType) { + case SupportedFirmwareTypes.pico: + return "https://micropython.org/download/RPI_PICO/"; + case SupportedFirmwareTypes.picow: + return "https://micropython.org/download/RPI_PICO_W/"; + case SupportedFirmwareTypes.pico2: + return "https://micropython.org/download/RPI_PICO2/"; + } +} + +async function extractUf2Url( + url: string, + allowPreview: boolean +): Promise { + try { + // Fetch the content of the URL using Undici + const { body, headers } = await request(url); + const contentType = headers["content-type"]; + + // Check if the content is HTML + if (contentType?.includes("text/html")) { + let html = ""; + for await (const chunk of body) { + html += chunk; + } + + // Split the document at

Firmware

+ const splitHtml = html.split("

Firmware

"); + if (splitHtml.length < 2) { + console.log("No Firmware section found."); + + return null; + } + + const firmwareSection = splitHtml[1]; + + // Use regex to find the first .uf2 URL inside tags + const uf2Regex = allowPreview + ? /]+href="([^"]+\.uf2)"/ + : /\s*]+href="([^"]+\.uf2)"/; + const match = uf2Regex.exec(firmwareSection); + + if (match?.[1]) { + return match[1]; + } else { + console.log("No .uf2 link found inside ."); + + return null; + } + } else { + console.log("The URL did not return HTML content."); + + return null; + } + } catch (error) { + console.error("Error fetching or processing the URL:", error); + + return null; + } +} + +export async function downloadFirmware( + firmwareType: SupportedFirmwareTypes +): Promise { + const url = firmwareTypeToDownloadURL(firmwareType); + const uf2Url = await extractUf2Url( + url, + // TODO: remove after stable builds for Pico2 are available + firmwareType === SupportedFirmwareTypes.pico2 + ); + + if (!uf2Url) { + console.error("No UF2 URL found."); + + return; + } + + try { + // Fetch the .uf2 file using Undici + const { body } = await request(`https://micropython.org${uf2Url}`); + + // Get the filename from the URL + const fileName = basename(uf2Url); + + // Create the path for the file in the system temp directory + const tempDir = tmpdir(); + const filePath = join(tempDir, fileName); + + // Stream the file content to the temp directory + const fileStream = createWriteStream(filePath); + body.pipe(fileStream); + + await new Promise((resolve, reject) => { + fileStream.on("finish", resolve); + fileStream.on("error", reject); + }); + + console.log(`Firmware downloaded to: ${filePath}`); + + return filePath; + } catch (error) { + console.error("Error downloading the UF2 file:", error); + + return; + } +} diff --git a/src/flash.mts b/src/flash.mts new file mode 100644 index 0000000..20b0d12 --- /dev/null +++ b/src/flash.mts @@ -0,0 +1,328 @@ +import { + commands, + env, + extensions, + ProgressLocation, + type QuickPickItem, + ThemeIcon, + Uri, + window, +} from "vscode"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { + downloadFirmware, + SupportedFirmwareTypes, +} from "./downloadFirmware.mjs"; + +const execAsync = promisify(exec); + +export async function flashPicoInteractively( + verbose = false +): Promise { + // check if raspberry pi pico extension is installed + const picoExtension = extensions.getExtension( + "raspberry-pi.raspberry-pi-pico" + ); + + // TODO: maybe show hint to use + if (picoExtension === undefined) { + if (verbose) { + const result = await window.showErrorMessage( + "The Raspberry Pi Pico extension is not installed. " + + "Please install it from the marketplace.", + "Open Marketplace" + ); + + if (result === "Open Marketplace") { + void env.openExternal( + Uri.parse("vscode:extension/raspberry-pi.raspberry-pi-pico") + ); + } + } + + return false; + } // maybe pause auto connect when found + + // check if the extension is active + if (!picoExtension.isActive) { + await picoExtension.activate(); + } + + // get the picotool path + const picotoolPath = await commands.executeCommand( + "raspberry-pi-pico.getPicotoolPath" + ); + + // TODO: show use feedback + if (picotoolPath === undefined) { + if (verbose) { + void window.showErrorMessage( + "Failed to get picotool path from the Raspberry Pi Pico extension." + ); + } + + return false; + } + + interface PicoDevice { + bus: string; + address: string; + type: string; + flashSize: string; + } + let devices: PicoDevice[] | { is2040: boolean; type: string } | undefined; + + try { + // execute picotoolPath info -d + const { stdout } = await execAsync(`${picotoolPath} info -d`); + if (stdout.length <= 0) { + if (verbose) { + void window.showErrorMessage( + "Failed to get any connected devices. " + + "Please make sure your board is in BOOTSEL mode." + ); + } + + return false; + } + + // means multiple devices found + if (stdout.includes("Device at bus")) { + const regex = + // eslint-disable-next-line max-len + /Device at bus (\d+), address (\d+):[\s\S]*?type:\s+(\w+)[\s\S]*?flash size:\s+(\d+K)/g; + + let match; + devices = []; + + while ((match = regex.exec(stdout)) !== null) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, bus, address, type, flashSize] = match; + devices.push({ bus, address, type, flashSize }); + } + } else { + const type = /type:\s+(\w+)/.exec(stdout)?.[1]; + + if (type === undefined) { + if (verbose) { + void window.showErrorMessage( + "Failed to get device type. " + + "Please make sure your board is in BOOTSEL mode." + ); + } + + return false; + } + + devices = { is2040: type === "RP2040", type }; + } + } catch (error) { + /*this.logger.debug( + "Failed to check for USB MSDs:", + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error" + );*/ + // too much logging if auto-connect is using this + /*console.debug( + "Failed to check for USB MSDs:", + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error" + );*/ + + if (verbose) { + void window.showErrorMessage( + "Failed to check for connected devices. " + + "Please make sure your board is in BOOTSEL mode." + ); + } + + // probably not exit code zero + return false; + } + + if (devices !== undefined) { + const result = await window.showInformationMessage( + "Found a connected Pico in BOOTSEL mode. Before you can use it with " + + "this extension, you need to flash the MicroPython firmware to it. " + + "Do you want to flash it now? (Raspberry Pi boards only)", + "Yes", + "Flash manually", + "Search only for MicroPython boards" + ); + + if (result !== "Yes") { + if (result === "Flash manually") { + // open micropython download website + void env.openExternal(Uri.parse("https://micropython.org/download/")); + } + + //this.noCheckForUSBMSDs = true; + return true; + } + + let device: + | { bus: string; address: string; is2040: boolean; type: string } + | { is2040: boolean; type: string } + | undefined; + + // vscode quick pick item with bus and address + interface DeviceQuickPickItem extends QuickPickItem { + bus: string; + address: string; + isRP2040: boolean; + type: string; + } + + if (devices instanceof Array) { + // ask user which defvice to flash + const deviceSelection = await window.showQuickPick( + devices.map(d => ({ + label: `${d.type} (${d.flashSize} flash)`, + detail: `Bus: ${d.bus}, Address: ${d.address}`, + iconPath: new ThemeIcon("device"), + bus: d.bus, + address: d.address, + isRP2040: d.type === "RP2040", + type: d.type, + })) as DeviceQuickPickItem[], + { + canPickMany: false, + placeHolder: "Select the device you want to flash", + ignoreFocusOut: false, + } + ); + + if (deviceSelection === undefined) { + return false; + } + + device = { + bus: deviceSelection.bus, + address: deviceSelection.address, + is2040: deviceSelection.isRP2040, + type: deviceSelection.type, + }; + } else { + device = devices; + } + + let wirelessFirmware = false; + + // if the type is RP2040 ask if the user wants to flash Wireless firmware or not + // else just flash the non wireless firmware for the other boards + if (device?.is2040) { + const flashWireless = await window.showInformationMessage( + "Do you want to flash the Wireless firmware?", + "Yes", + "No" + ); + + if (flashWireless === "Yes") { + wirelessFirmware = true; + } + } + + let firmwareType: SupportedFirmwareTypes | undefined; + + switch (device.type) { + case "RP2040": + firmwareType = wirelessFirmware + ? SupportedFirmwareTypes.picow + : SupportedFirmwareTypes.pico; + break; + + case "RP2350": + firmwareType = SupportedFirmwareTypes.pico2; + break; + } + + if (firmwareType === undefined) { + // TODO: disable auto connect check for MSDs and show button for download link + void window.showErrorMessage( + "Unsupported board type. Please flash the firmware manually." + ); + + return false; + } + + let firmwarePath: string | undefined; + + // download firmware + await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Downloading firmware...", + cancellable: false, + }, + async progress => { + // TODO: implement + // cancellation is not possible + + firmwarePath = await downloadFirmware(firmwareType); + progress.report({ increment: 100 }); + } + ); + + void window.showInformationMessage("Firmware downloaded. Now flashing..."); + + // flash with picotool load -x --ignore-partitions --bus --address
+ window.withProgress( + { + location: ProgressLocation.Notification, + title: "Flashing firmware...", + cancellable: false, + }, + async progress => { + const command = `${picotoolPath} load -x --ignore-partitions ${ + (device && "bus" in device && "address" in device + ? `--bus ${device.bus} --address ${device.address} ` + : "") + firmwarePath + }`; + + try { + await execAsync(command); + progress.report({ increment: 100 }); + void window.showInformationMessage( + "Firmware flashed successfully. Trying to connect..." + ); + } catch (error) { + progress.report({ increment: 100 }); + void window.showErrorMessage( + "Failed to flash firmware: " + + (error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error") + ); + /*this.logger.error( + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error" + );*/ + + console.error( + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error" + ); + } + } + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + return false; +}