diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js
new file mode 100644
index 0000000..8b51313
--- /dev/null
+++ b/js/common/circuitpython_highlight.js
@@ -0,0 +1,221 @@
+// CircuitPython syntax highlighting overlay for CodeMirror 6.
+//
+// CodeMirror 6 dropped the simple `extra_keywords` mechanism that CM5 had,
+// so instead of forking @codemirror/lang-python we layer extra decorations
+// on top of the existing Python syntax tree. We walk the tree inside the
+// viewport, find identifier nodes whose text matches a CircuitPython name,
+// and tag them with a CSS class that the theme can style.
+
+import { ViewPlugin, Decoration } from "@codemirror/view";
+import { syntaxTree } from "@codemirror/language";
+import { Prec } from "@codemirror/state";
+
+// Core/built-in CircuitPython modules. These are the identifiers that show
+// up in `import foo` / `from foo import ...` inside CircuitPython code.
+//
+// Sourced from the upstream `shared-bindings/` directory in
+// adafruit/circuitpython, plus port-specific bindings that are widely used
+// (espidf/espnow/espulp on ESP, picodvi/rp2pio on RP2). Standard-Python
+// modules that CircuitPython also exposes (math, os, time, random, struct,
+// hashlib, ipaddress, locale, __future__) are intentionally omitted — they
+// aren't CircuitPython-specific and highlighting them as such would be
+// noisy in regular Python code shown in the editor.
+//
+// Underscore-prefixed internal bindings (_bleio, _eve, _pew, _pixelmap,
+// _stage) are also omitted; users access those via the corresponding
+// `adafruit_*` libraries which are matched by the prefix wildcard below.
+//
+// Third-party Adafruit libraries are matched by the `adafruit_` prefix
+// instead of being listed individually, and community-bundle libraries by
+// the `circuitpython_` prefix, so this set only needs updating when a new
+// shared binding lands upstream.
+const CIRCUITPYTHON_CORE_MODULES = new Set([
+ "aesio",
+ "alarm",
+ "analogbufio",
+ "analogio",
+ "atexit",
+ "audiobusio",
+ "audiocore",
+ "audiodelays",
+ "audiofilters",
+ "audiofreeverb",
+ "audioio",
+ "audiomixer",
+ "audiomp3",
+ "audiopwmio",
+ "audiospeed",
+ "aurora_epaper",
+ "bitbangio",
+ "bitmapfilter",
+ "bitmaptools",
+ "bitops",
+ "board",
+ "busdisplay",
+ "busio",
+ "camera",
+ "canio",
+ "codeop",
+ "countio",
+ "digitalio",
+ "displayio",
+ "dotclockframebuffer",
+ "dualbank",
+ "epaperdisplay",
+ "espidf",
+ "espnow",
+ "espulp",
+ "floppyio",
+ "fontio",
+ "fourwire",
+ "framebufferio",
+ "frequencyio",
+ "getpass",
+ "gifio",
+ "gnss",
+ "i2cdisplaybus",
+ "i2cioexpander",
+ "i2ctarget",
+ "imagecapture",
+ "is31fl3741",
+ "jpegio",
+ "keypad",
+ "keypad_demux",
+ "lvfontio",
+ "max3421e",
+ "mcp4822",
+ "mdns",
+ "memorymap",
+ "memorymonitor",
+ "microcontroller",
+ "mipidsi",
+ "msgpack",
+ "neopixel_write",
+ "nvm",
+ "onewireio",
+ "paralleldisplaybus",
+ "picodvi",
+ "ps2io",
+ "pulseio",
+ "pwmio",
+ "qrio",
+ "qspibus",
+ "rainbowio",
+ "rclcpy",
+ "rgbmatrix",
+ "rotaryio",
+ "rp2pio",
+ "rtc",
+ "sdcardio",
+ "sdioio",
+ "sharpdisplay",
+ "socketpool",
+ "spitarget",
+ "ssl",
+ "storage",
+ "supervisor",
+ "synthio",
+ "terminalio",
+ "tilepalettemapper",
+ "touchio",
+ "traceback",
+ "uheap",
+ "ulab",
+ "usb",
+ "usb_cdc",
+ "usb_hid",
+ "usb_host",
+ "usb_midi",
+ "usb_video",
+ "ustack",
+ "vectorio",
+ "warnings",
+ "watchdog",
+ "wifi",
+ "zlib",
+
+ // Early Adafruit-maintained libraries that predate the `adafruit_`
+ // naming convention and shipped without a prefix. Listed explicitly
+ // because the prefix wildcard below can't catch them.
+ "neopixel",
+ "simpleio",
+]);
+
+// Returns true when `name` is a CircuitPython module worth highlighting.
+// Wildcard-matches anything starting with `adafruit_` (Adafruit-maintained
+// libraries) or `circuitpython_` (community bundle libraries) so new
+// libraries light up automatically without touching this file. Both
+// prefixes are distinctive enough that false positives against ordinary
+// Python code are essentially nil.
+function isCircuitPythonModule(name) {
+ if (CIRCUITPYTHON_CORE_MODULES.has(name)) return true;
+ if (name.startsWith("adafruit_") && name.length > "adafruit_".length) {
+ return true;
+ }
+ if (
+ name.startsWith("circuitpython_") &&
+ name.length > "circuitpython_".length
+ ) {
+ return true;
+ }
+ return false;
+}
+
+const moduleMark = Decoration.mark({ class: "tok-cp-module" });
+
+// Build the decoration set for the part of the document currently visible.
+// Walking only visible ranges keeps this cheap on big files.
+function buildDecorations(view) {
+ const builder = [];
+ for (const { from, to } of view.visibleRanges) {
+ syntaxTree(view.state).iterate({
+ from,
+ to,
+ enter(node) {
+ // We care about identifier-like leaves only. Lezer Python emits
+ // `VariableName` for bare identifiers (including module names
+ // in `import foo` and `from foo import ...`). Module names
+ // accessed as attributes (e.g. `adafruit_io.MQTT`) come in as
+ // `VariableName` for the leftmost part, then `PropertyName`
+ // children — we only mark the root reference.
+ if (node.name !== "VariableName") return;
+ const text = view.state.doc.sliceString(node.from, node.to);
+ if (isCircuitPythonModule(text)) {
+ builder.push(moduleMark.range(node.from, node.to));
+ }
+ },
+ });
+ }
+ // Decoration ranges must be sorted by `from`, which they already are
+ // because we iterate the tree in document order.
+ return Decoration.set(builder);
+}
+
+// ViewPlugin keeps decorations in sync with viewport / document changes.
+const circuitpythonHighlightPlugin = ViewPlugin.fromClass(
+ class {
+ constructor(view) {
+ this.decorations = buildDecorations(view);
+ }
+ update(update) {
+ if (
+ update.docChanged ||
+ update.viewportChanged ||
+ syntaxTree(update.startState) !== syntaxTree(update.state)
+ ) {
+ this.decorations = buildDecorations(update.view);
+ }
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ },
+);
+
+// Wrap the plugin with Prec.highest so its decoration nests inside the
+// classHighlighter span. CodeMirror renders overlapping mark decorations
+// as nested spans where higher-precedence decorations end up closer to
+// the text. The inner span’s `color` is what the user sees, so making
+// `tok-cp-module` the inner class is what lets our pink override the
+// underlying `tok-variableName` blue without resorting to !important.
+export const circuitpythonHighlight = Prec.highest(circuitpythonHighlightPlugin);
diff --git a/js/common/dialogs.js b/js/common/dialogs.js
index 4c0295c..42e2f7d 100644
--- a/js/common/dialogs.js
+++ b/js/common/dialogs.js
@@ -1,4 +1,5 @@
import {sleep, isIp, switchDevice} from './utilities.js';
+import {renderFirmwareSuggestions} from './firmware-check.js';
import * as focusTrap from 'focus-trap';
const SELECTOR_CLOSE_BUTTON = ".popup-modal__close";
@@ -357,6 +358,10 @@ class DiscoveryModal extends GenericModal {
this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name;
this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id;
this._currentModal.querySelector("#uid").textContent = deviceInfo.uid;
+ const updateContainer = this._currentModal.querySelector("#firmware-update");
+ if (updateContainer) {
+ renderFirmwareSuggestions(updateContainer, deviceInfo);
+ }
}
async _refreshDevices() {
@@ -417,6 +422,10 @@ class DeviceInfoModal extends GenericModal {
this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name;
this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id;
this._currentModal.querySelector("#uid").textContent = deviceInfo.uid;
+ const updateContainer = this._currentModal.querySelector("#firmware-update");
+ if (updateContainer) {
+ renderFirmwareSuggestions(updateContainer, deviceInfo);
+ }
}
async open(workflow, documentState) {
diff --git a/js/common/firmware-check.js b/js/common/firmware-check.js
new file mode 100644
index 0000000..8b56f40
--- /dev/null
+++ b/js/common/firmware-check.js
@@ -0,0 +1,200 @@
+// Helpers for comparing CircuitPython firmware versions and surfacing
+// "newer firmware available" suggestions in the UI.
+//
+// CircuitPython release tags follow a SemVer-ish form, e.g.:
+// 9.2.8 stable
+// 10.0.0-alpha.1 development pre-release
+// 10.0.0-beta.0
+// 10.0.0-rc.0
+//
+// We parse the version into a tuple we can compare numerically and use the
+// GitHub releases API for adafruit/circuitpython to find the latest stable
+// and the latest dev (prerelease) versions. Per-board availability is left
+// to the linked board page on circuitpython.org, which only lists builds
+// that actually exist for that board.
+//
+// See https://github.com/circuitpython/web-editor/issues/357
+
+const RELEASES_API = "https://api.github.com/repos/adafruit/circuitpython/releases";
+
+// Cache the API result for the lifetime of the page so opening the device
+// info dialog repeatedly doesn't hammer the API.
+let _releasesPromise = null;
+
+// Pre-release identifier ranking. Lower number = earlier in the release cycle.
+// Anything not listed (or an empty pre-release section) is treated as a final
+// stable release and ranks highest within the same X.Y.Z.
+const PRERELEASE_RANK = {
+ "alpha": 0,
+ "beta": 1,
+ "rc": 2,
+};
+
+// Parse a version string like "9.2.8", "10.0.0-alpha.1", or "10.0.0-rc.0"
+// into a comparable structure. Returns null if it can't be parsed.
+function parseVersion(versionString) {
+ if (typeof versionString !== "string") return null;
+ // Trim a leading "v" and any trailing build metadata after "+"
+ let raw = versionString.trim().replace(/^v/i, "").split("+", 1)[0];
+ // Tolerate "-dirty" suffix on builds compiled from a working tree
+ raw = raw.replace(/-dirty$/i, "");
+ const match = raw.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.]([A-Za-z]+)\.?(\d+)?)?$/);
+ if (!match) return null;
+
+ const [, maj, min, patch, preLabel, preNum] = match;
+ let preRank = Number.POSITIVE_INFINITY; // stable releases rank above any pre-release
+ let preNumber = 0;
+ let isPrerelease = false;
+ if (preLabel) {
+ isPrerelease = true;
+ const label = preLabel.toLowerCase();
+ preRank = label in PRERELEASE_RANK ? PRERELEASE_RANK[label] : -1;
+ preNumber = preNum ? parseInt(preNum, 10) : 0;
+ }
+ return {
+ raw: versionString,
+ major: parseInt(maj, 10),
+ minor: parseInt(min, 10),
+ patch: parseInt(patch, 10),
+ prerelease: isPrerelease,
+ preRank,
+ preNumber,
+ };
+}
+
+// Compare two parsed versions. Returns negative if a < b, positive if a > b, 0 if equal.
+function compareVersions(a, b) {
+ if (!a && !b) return 0;
+ if (!a) return -1;
+ if (!b) return 1;
+ if (a.major !== b.major) return a.major - b.major;
+ if (a.minor !== b.minor) return a.minor - b.minor;
+ if (a.patch !== b.patch) return a.patch - b.patch;
+ if (a.preRank !== b.preRank) return a.preRank - b.preRank;
+ return a.preNumber - b.preNumber;
+}
+
+// Fetch (and cache) the list of CircuitPython releases from GitHub and pick
+// the highest stable + highest dev pre-release. Returns
+// { stable: parsedVersion|null, dev: parsedVersion|null }.
+async function fetchLatestReleases() {
+ if (_releasesPromise) return _releasesPromise;
+
+ _releasesPromise = (async () => {
+ let response;
+ try {
+ response = await fetch(`${RELEASES_API}?per_page=30`, {
+ headers: {"Accept": "application/vnd.github+json"},
+ });
+ } catch (err) {
+ console.warn("Firmware check: fetch failed", err);
+ return {stable: null, dev: null};
+ }
+ if (!response.ok) {
+ console.warn("Firmware check: GitHub API returned", response.status);
+ return {stable: null, dev: null};
+ }
+ let releases;
+ try {
+ releases = await response.json();
+ } catch (err) {
+ console.warn("Firmware check: bad JSON from GitHub", err);
+ return {stable: null, dev: null};
+ }
+
+ let stable = null;
+ let dev = null;
+ for (const release of releases) {
+ if (release.draft) continue;
+ const parsed = parseVersion(release.tag_name);
+ if (!parsed) continue;
+ if (release.prerelease || parsed.prerelease) {
+ if (compareVersions(parsed, dev) > 0) dev = parsed;
+ } else {
+ if (compareVersions(parsed, stable) > 0) stable = parsed;
+ }
+ }
+ return {stable, dev};
+ })();
+
+ return _releasesPromise;
+}
+
+// Decide which (if any) firmware suggestions to surface for a device that
+// is currently running `currentVersionString`. Implements the logic from
+// https://github.com/circuitpython/web-editor/issues/357:
+//
+// - If the user is running a development release:
+// - Suggest the latest stable if it is newer.
+// - Suggest the latest dev release if it is newer than what they're running.
+// - If the user is running a stable release:
+// - Suggest a newer stable, if any.
+// - Suggest a newer dev release, if any.
+//
+// Returns { suggestions: [{type: "stable"|"dev", version: "10.0.0"}], current }.
+function buildSuggestions(currentVersionString, latestReleases) {
+ const current = parseVersion(currentVersionString);
+ const suggestions = [];
+ if (!current || !latestReleases) {
+ return {suggestions, current};
+ }
+ const {stable, dev} = latestReleases;
+
+ if (stable && compareVersions(stable, current) > 0) {
+ suggestions.push({type: "stable", version: stable.raw});
+ }
+ if (dev && compareVersions(dev, current) > 0) {
+ suggestions.push({type: "dev", version: dev.raw});
+ }
+ return {suggestions, current};
+}
+
+// Format suggestions as a small HTML snippet suitable for injecting into a
+// device info table. `boardId` is used to deep-link to the board's download
+// page on circuitpython.org. Returns an empty string when there is nothing
+// to suggest.
+function renderSuggestionsHtml(suggestions, boardId) {
+ if (!suggestions || suggestions.length === 0) return "";
+ const safeBoard = encodeURIComponent(boardId || "");
+ const link = safeBoard
+ ? `https://circuitpython.org/board/${safeBoard}/`
+ : "https://circuitpython.org/downloads";
+ const items = suggestions.map((s) => {
+ const label = s.type === "dev" ? "development release" : "stable release";
+ return `