From abf508b84ee1a8ad9c9762a873216c80aaa6458c Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Mon, 27 Jul 2026 14:40:25 -0400 Subject: [PATCH] feat(examples): bundle the ESP32 self-profile example Adds the sketch from the hardware session as a first-party example under a "99.ArduinoAgent" category, so it appears in File > Examples and through the MCP list_examples / from_example tools next to the stock Arduino ones. It is a useful thing to ship: it needs nothing connected but USB, and it demonstrates the whole agent loop end to end - it profiles the chip's memory bandwidth (internal SRAM vs PSRAM), CPU throughput at two clock speeds, and the die temperature through an idle/load/cooldown cycle, emitting JSON lines an agent can parse directly. The sketch lives in arduino-mcp-extension/examples/ (tracked in git) and is copied into the bundled tree by scripts/bundle-agent-examples.js, wired into arduino-ide-extension's prepare step. It is a separate script rather than a change to download-examples.js because that one returns early whenever the Examples directory already exists - the usual case after the first build - so anything added there would silently stop running on rebuilds. This script is idempotent: it replaces its own examples.json entries instead of appending, so repeated runs cannot duplicate them. Verified against the installed app: list_examples reports the new category and from_example clones it into the sketchbook. Co-Authored-By: Claude Opus 4.8 --- arduino-ide-extension/package.json | 5 +- .../ESP32_SelfProfile/ESP32_SelfProfile.ino | 266 ++++++++++++++++++ scripts/bundle-agent-examples.js | 105 +++++++ 3 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 arduino-mcp-extension/examples/99.ArduinoAgent/ESP32_SelfProfile/ESP32_SelfProfile.ino create mode 100644 scripts/bundle-agent-examples.js diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index b3448be87f6..61f60a95068 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -4,7 +4,7 @@ "description": "An extension for Theia building the Arduino IDE", "license": "AGPL-3.0-or-later", "scripts": { - "prepare": "yarn download-cli && yarn download-fwuploader && yarn download-ls && yarn copy-i18n && yarn download-examples", + "prepare": "yarn download-cli && yarn download-fwuploader && yarn download-ls && yarn copy-i18n && yarn download-examples && yarn bundle-agent-examples", "clean": "rimraf lib", "compose-changelog": "node ./scripts/compose-changelog.js", "download-cli": "node ./scripts/download-cli.js", @@ -20,7 +20,8 @@ "postbuild": "ncp ./src/node/cli-protocol/ ./lib/node/cli-protocol/", "watch": "tsc -w", "test": "cross-env IDE2_TEST=true mocha \"./lib/test/**/*.test.js\"", - "test:slow": "cross-env IDE2_TEST=true mocha \"./lib/test/**/*.slow-test.js\" --slow 5000" + "test:slow": "cross-env IDE2_TEST=true mocha \"./lib/test/**/*.slow-test.js\" --slow 5000", + "bundle-agent-examples": "node ../scripts/bundle-agent-examples.js" }, "dependencies": { "@grpc/grpc-js": "^1.8.14", diff --git a/arduino-mcp-extension/examples/99.ArduinoAgent/ESP32_SelfProfile/ESP32_SelfProfile.ino b/arduino-mcp-extension/examples/99.ArduinoAgent/ESP32_SelfProfile/ESP32_SelfProfile.ino new file mode 100644 index 00000000000..42288d0dff4 --- /dev/null +++ b/arduino-mcp-extension/examples/99.ArduinoAgent/ESP32_SelfProfile/ESP32_SelfProfile.ino @@ -0,0 +1,266 @@ +// ESP32-S3 self-profile — the board measures its own silicon. +// +// Needs nothing connected but USB. Written, compiled, flashed and read back +// entirely through the Arduino Agent MCP server. +// +// Emits JSON lines so an agent can parse the results directly: +// {"section":"chip"|"memory"|"cpu", ...} +// {"t":,"phase":"idle"|"load"|"cool","tempC":,"mhz":} +// +// The radios stay off for the whole run: WiFi/BLE would add their own heat and +// contaminate the thermal curve. + +#include +#include +#include + +#ifndef LED_BUILTIN +#define LED_BUILTIN 2 +#endif + +// ---- dual-core load control ------------------------------------------------- +volatile bool burning = false; +volatile double sinkA = 0.0; // volatile sinks stop the optimiser deleting the work +volatile double sinkB = 0.0; + +static void burnTask(void* arg) { + (void)arg; + double acc = 1.0; + uint32_t lastYield = millis(); + for (;;) { + if (burning) { + for (int i = 0; i < 50000; i++) { + acc = acc * 1.0000001 + 0.0000001; + } + sinkB = acc; + if (acc > 1e30) acc = 1.0; + // A tight loop at priority 1 starves FreeRTOS' IDLE0 task, which is what + // feeds the task watchdog - core 0 panics and the chip reboots mid-run. + // Yield ~1ms every 200ms: enough for IDLE to run, still >99% duty cycle + // so the thermal measurement is unaffected. + if (millis() - lastYield >= 200) { + lastYield = millis(); + vTaskDelay(1); + } + } else { + acc = 1.0; + vTaskDelay(20 / portTICK_PERIOD_MS); + } + } +} + +// ---- helpers --------------------------------------------------------------- +static const char* resetReasonName(esp_reset_reason_t r) { + switch (r) { + case ESP_RST_POWERON: return "power-on"; + case ESP_RST_EXT: return "external"; + case ESP_RST_SW: return "software"; + case ESP_RST_PANIC: return "panic"; + case ESP_RST_INT_WDT: return "int-watchdog"; + case ESP_RST_TASK_WDT: return "task-watchdog"; + case ESP_RST_WDT: return "other-watchdog"; + case ESP_RST_DEEPSLEEP:return "deep-sleep-wake"; + case ESP_RST_BROWNOUT: return "brownout"; + case ESP_RST_SDIO: return "sdio"; + default: return "unknown"; + } +} + +// Integer throughput: millions of add/xor ops per second. +static double benchInt() { + const uint32_t ITER = 4000000; + volatile uint32_t acc = 0; + uint32_t t0 = micros(); + for (uint32_t i = 0; i < ITER; i++) { + acc += i ^ (acc >> 3); + } + uint32_t dt = micros() - t0; + sinkA = (double)acc; + return (double)ITER / (double)dt; // ops per microsecond == Mops/s +} + +// Float throughput: millions of multiply-adds per second. +static double benchFloat() { + const uint32_t ITER = 2000000; + volatile float acc = 1.0f; + float a = 1.0000001f; + uint32_t t0 = micros(); + for (uint32_t i = 0; i < ITER; i++) { + acc = acc * a + 0.0000001f; + } + uint32_t dt = micros() - t0; + sinkA = (double)acc; + return (double)ITER / (double)dt; +} + +// memcpy bandwidth in MB/s for a given heap capability. +static double benchBandwidth(uint32_t caps, size_t bytes, int reps) { + uint8_t* src = (uint8_t*)heap_caps_malloc(bytes, caps | MALLOC_CAP_8BIT); + uint8_t* dst = (uint8_t*)heap_caps_malloc(bytes, caps | MALLOC_CAP_8BIT); + if (!src || !dst) { + if (src) heap_caps_free(src); + if (dst) heap_caps_free(dst); + return -1.0; + } + memset(src, 0xA5, bytes); + uint32_t t0 = micros(); + for (int i = 0; i < reps; i++) { + memcpy(dst, src, bytes); + } + uint32_t dt = micros() - t0; + heap_caps_free(src); + heap_caps_free(dst); + if (dt == 0) return -1.0; + double totalMB = ((double)bytes * reps) / (1024.0 * 1024.0); + return totalMB / ((double)dt / 1e6); +} + +static void sampleTemp(int tsec, const char* phase) { + Serial.print("{\"t\":"); + Serial.print(tsec); + Serial.print(",\"phase\":\""); + Serial.print(phase); + Serial.print("\",\"tempC\":"); + Serial.print(temperatureRead(), 2); + Serial.print(",\"mhz\":"); + Serial.print(getCpuFrequencyMhz()); + Serial.println("}"); +} + +// Busy-waits ~1s on this core while the pinned task loads the other one. +static void burnOneSecond() { + uint32_t t0 = millis(); + uint32_t lastYield = t0; + double acc = 1.0; + while (millis() - t0 < 1000) { + for (int i = 0; i < 20000; i++) { + acc = acc * 1.0000001 + 0.0000001; + } + if (acc > 1e30) acc = 1.0; + if (millis() - lastYield >= 200) { // same watchdog courtesy on core 1 + lastYield = millis(); + vTaskDelay(1); + } + } + sinkA = acc; +} + +void setup() { + Serial.begin(115200); + delay(1200); + pinMode(LED_BUILTIN, OUTPUT); + + xTaskCreatePinnedToCore(burnTask, "burn", 4096, nullptr, 1, nullptr, 0); + + Serial.println(); + Serial.println("PROFILE_START"); + + // ---- chip identity ---- + uint64_t mac = ESP.getEfuseMac(); + char macStr[18]; + snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", + (uint8_t)(mac >> 0), (uint8_t)(mac >> 8), (uint8_t)(mac >> 16), + (uint8_t)(mac >> 24), (uint8_t)(mac >> 32), (uint8_t)(mac >> 40)); + + Serial.print("{\"section\":\"chip\",\"model\":\""); + Serial.print(ESP.getChipModel()); + Serial.print("\",\"revision\":"); + Serial.print(ESP.getChipRevision()); + Serial.print(",\"cores\":"); + Serial.print(ESP.getChipCores()); + Serial.print(",\"mhz\":"); + Serial.print(getCpuFrequencyMhz()); + Serial.print(",\"mac\":\""); + Serial.print(macStr); + Serial.print("\",\"sdk\":\""); + Serial.print(ESP.getSdkVersion()); + Serial.print("\",\"resetReason\":\""); + Serial.print(resetReasonName(esp_reset_reason())); + Serial.println("\"}"); + + // ---- memory ---- + Serial.print("{\"section\":\"memory\",\"flashBytes\":"); + Serial.print(ESP.getFlashChipSize()); + Serial.print(",\"flashMhz\":"); + Serial.print(ESP.getFlashChipSpeed() / 1000000); + Serial.print(",\"heapBytes\":"); + Serial.print(ESP.getHeapSize()); + Serial.print(",\"freeHeap\":"); + Serial.print(ESP.getFreeHeap()); + Serial.print(",\"psramBytes\":"); + Serial.print(ESP.getPsramSize()); + Serial.print(",\"freePsram\":"); + Serial.print(ESP.getFreePsram()); + Serial.println("}"); + + // ---- bandwidth: internal SRAM vs external PSRAM ---- + double bwInternal = benchBandwidth(MALLOC_CAP_INTERNAL, 64 * 1024, 64); + double bwPsram = benchBandwidth(MALLOC_CAP_SPIRAM, 64 * 1024, 64); + + // ---- cpu throughput at full clock, then throttled ---- + int fullMhz = getCpuFrequencyMhz(); + double intFull = benchInt(); + double fltFull = benchFloat(); + + setCpuFrequencyMhz(80); + delay(50); + int lowMhz = getCpuFrequencyMhz(); + double intLow = benchInt(); + double fltLow = benchFloat(); + + setCpuFrequencyMhz(fullMhz); + delay(50); + + Serial.print("{\"section\":\"cpu\",\"fullMhz\":"); + Serial.print(fullMhz); + Serial.print(",\"lowMhz\":"); + Serial.print(lowMhz); + Serial.print(",\"intMopsFull\":"); + Serial.print(intFull, 2); + Serial.print(",\"intMopsLow\":"); + Serial.print(intLow, 2); + Serial.print(",\"floatMopsFull\":"); + Serial.print(fltFull, 2); + Serial.print(",\"floatMopsLow\":"); + Serial.print(fltLow, 2); + Serial.print(",\"sramMBs\":"); + Serial.print(bwInternal, 1); + Serial.print(",\"psramMBs\":"); + Serial.print(bwPsram, 1); + Serial.println("}"); + + // ---- thermal response: idle -> both cores 100% -> cooldown ---- + Serial.println("THERMAL_START"); + int t = 0; + + for (int i = 0; i < 8; i++, t++) { // idle baseline + sampleTemp(t, "idle"); + delay(1000); + } + + burning = true; // core 0 task starts working + digitalWrite(LED_BUILTIN, HIGH); + for (int i = 0; i < 30; i++, t++) { // both cores pinned at 100% + burnOneSecond(); // core 1 busy-waits ~1s + sampleTemp(t, "load"); + } + burning = false; + digitalWrite(LED_BUILTIN, LOW); + + for (int i = 0; i < 30; i++, t++) { // cooldown + sampleTemp(t, "cool"); + delay(1000); + } + + Serial.println("THERMAL_END"); + Serial.println("PROFILE_END"); +} + +void loop() { + digitalWrite(LED_BUILTIN, HIGH); + delay(100); + digitalWrite(LED_BUILTIN, LOW); + delay(1900); + Serial.print("idle tempC="); + Serial.println(temperatureRead(), 2); +} diff --git a/scripts/bundle-agent-examples.js b/scripts/bundle-agent-examples.js new file mode 100644 index 00000000000..3086101f0d3 --- /dev/null +++ b/scripts/bundle-agent-examples.js @@ -0,0 +1,105 @@ +// @ts-check + +// Copies this fork's own example sketches into the bundled Examples tree and +// registers them in examples.json, so they show up in File > Examples and in the +// MCP `list_examples` / `from_example` tools alongside the stock Arduino ones. +// +// Why a separate script rather than a change to download-examples.js: that script +// returns early when the Examples directory already exists (the usual case after +// the first build), so anything added there would silently not run on rebuilds. +// This one is idempotent and safe to run every time. +// +// Source of truth is arduino-mcp-extension/examples///.ino, +// which is tracked in git. The destination lives under src/node/resources, which +// is gitignored build output. + +const { existsSync, promises: fs, cpSync } = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.join(__dirname, '..'); +const source = path.join(repoRoot, 'arduino-mcp-extension', 'examples'); +const destination = path.join( + repoRoot, + 'arduino-ide-extension', + 'src', + 'node', + 'resources', + 'Examples' +); +const manifest = path.join(destination, 'examples.json'); + +const isSketch = async (dir) => { + try { + const names = await fs.readdir(dir); + return names.includes(`${path.basename(dir)}.ino`); + } catch (e) { + if (e.code === 'ENOTDIR') return false; + throw e; + } +}; + +// Mirrors the shape download-examples.js writes: { label, children[], sketches[] } +const describeCategory = async (categoryPath, label) => { + const entry = { label, children: [], sketches: [] }; + for (const name of await fs.readdir(categoryPath)) { + const childPath = path.join(categoryPath, name); + if (await isSketch(childPath)) { + entry.sketches.push({ + name, + relativePath: path.relative(destination, childPath), + }); + } + } + return entry; +}; + +(async () => { + if (!existsSync(source)) { + console.log(`No fork examples at ${source}; nothing to bundle.`); + return; + } + if (!existsSync(destination)) { + console.log( + `Examples not downloaded yet (${destination} missing). Run download-examples first.` + ); + return; + } + + const categories = (await fs.readdir(source, { withFileTypes: true })) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + if (!categories.length) { + console.log('No fork example categories found.'); + return; + } + + for (const category of categories) { + cpSync(path.join(source, category), path.join(destination, category), { + recursive: true, + }); + console.log(` Bundled example category '${category}'.`); + } + + // Re-register in examples.json. Replace any previous entry for these + // categories so re-running never duplicates them. + /** @type {Array<{label: string}>} */ + let examples = []; + try { + examples = JSON.parse(await fs.readFile(manifest, { encoding: 'utf8' })); + } catch { + console.log(`Could not read ${manifest}; writing a fresh manifest.`); + } + examples = examples.filter((e) => !categories.includes(e.label)); + for (const category of categories) { + examples.push( + await describeCategory(path.join(destination, category), category) + ); + } + examples.sort((a, b) => a.label.localeCompare(b.label)); + + await fs.writeFile(manifest, JSON.stringify(examples, null, 2), { + encoding: 'utf8', + }); + console.log(`Registered ${categories.length} category(ies) in examples.json.`); +})();