Browse Source

feat(firmware): list all announced versions with usable/unavailable status, support rollback

  Firmware update modal now shows every version from Bambu's wiki release
  history, each badged Usable/Unavailable/Installed. Selecting a usable row
  — newer or older than current — swaps the release notes and enables
  install for that version, so rollback no longer requires hand-flashing.

  Wiki scraper tightened to only read heading-anchor ids (h-XXXXXXXX-YYYYMMDD)
  instead of any XX.XX.XX.XX substring, eliminating false positives like an
  AMS firmware version mentioned in an H2D changelog being listed as H2D
  firmware.

  Refs #568
maziggy 1 month ago
parent
commit
d74ab06072

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Requires that the External URL setting (General tab) points to a hostname/IP reachable from the ML API container, since the ML API fetches snapshots by URL.
 
 ### Improved
+- **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id="h-01030000-20260303"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.
 - **Spoolbuddy Device Controls in Settings** ([#962](https://github.com/maziggy/bambuddy/issues/962)) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing `/spoolbuddy/devices/{id}/update` and `/spoolbuddy/devices/{id}/system/command` endpoints — no new backend work needed. Thanks to @TravisWilder for the request.
 - **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.
 

+ 1 - 1
README.md

@@ -230,7 +230,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Interval reminders (hours/days)
 - Print time accuracy stats
 - File manager for printer storage
-- Firmware update helper with version badge (LAN-only printers)
+- Firmware update helper with version badge (LAN-only printers) — lists all announced versions with Usable/Unavailable/Installed badges and supports rollback to older firmware
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)

+ 19 - 3
backend/app/api/routes/firmware.py

@@ -30,6 +30,16 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/firmware", tags=["firmware"])
 
 
+class AvailableFirmwareVersion(BaseModel):
+    """A single firmware version announced by Bambu Lab."""
+
+    version: str
+    file_available: bool
+    download_url: str | None = None
+    release_notes: str | None = None
+    release_time: str | None = None
+
+
 class FirmwareUpdateInfo(BaseModel):
     """Firmware update information for a printer."""
 
@@ -41,6 +51,7 @@ class FirmwareUpdateInfo(BaseModel):
     update_available: bool
     download_url: str | None = None
     release_notes: str | None = None
+    available_versions: list[AvailableFirmwareVersion] = Field(default_factory=list)
 
 
 class FirmwareUpdatesResponse(BaseModel):
@@ -106,6 +117,7 @@ async def check_firmware_updates(
                 update_available=update_info["update_available"],
                 download_url=update_info["download_url"],
                 release_notes=update_info["release_notes"],
+                available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get("available_versions", [])],
             )
         )
 
@@ -149,6 +161,7 @@ async def check_printer_firmware(
         update_available=update_info["update_available"],
         download_url=update_info["download_url"],
         release_notes=update_info["release_notes"],
+        available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get("available_versions", [])],
     )
 
 
@@ -192,6 +205,7 @@ class FirmwareUploadPrepareResponse(BaseModel):
     update_available: bool
     current_version: str | None = None
     latest_version: str | None = None
+    target_version: str | None = None
     firmware_filename: str | None = None
     errors: list[str] = Field(default_factory=list)
 
@@ -217,6 +231,7 @@ class FirmwareUploadStartResponse(BaseModel):
 @router.get("/updates/{printer_id}/prepare", response_model=FirmwareUploadPrepareResponse)
 async def prepare_firmware_upload(
     printer_id: int,
+    version: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
@@ -232,13 +247,14 @@ async def prepare_firmware_upload(
     can succeed.
     """
     update_service = get_firmware_update_service()
-    result = await update_service.prepare_update(printer_id, db)
+    result = await update_service.prepare_update(printer_id, db, target_version=version)
     return FirmwareUploadPrepareResponse(**result)
 
 
 @router.post("/updates/{printer_id}/upload", response_model=FirmwareUploadStartResponse)
 async def start_firmware_upload(
     printer_id: int,
+    version: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
@@ -257,7 +273,7 @@ async def start_firmware_upload(
     """
     # First check prerequisites
     update_service = get_firmware_update_service()
-    prepare_result = await update_service.prepare_update(printer_id, db)
+    prepare_result = await update_service.prepare_update(printer_id, db, target_version=version)
 
     if not prepare_result["can_proceed"]:
         errors = prepare_result.get("errors", ["Cannot proceed with firmware upload"])
@@ -267,7 +283,7 @@ async def start_firmware_upload(
         )
 
     # Start the upload
-    started = await update_service.start_upload(printer_id, db)
+    started = await update_service.start_upload(printer_id, db, target_version=version)
 
     if not started:
         state = get_upload_state(printer_id)

+ 163 - 41
backend/app/services/firmware_check.py

@@ -113,6 +113,7 @@ class FirmwareCheckService:
         self._build_id: str | None = None
         self._build_id_time: float = 0
         self._version_cache: dict[str, FirmwareVersion] = {}
+        self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
         self._cache_time: float = 0
         self._client = httpx.AsyncClient(
             timeout=30.0,
@@ -145,33 +146,63 @@ class FirmwareCheckService:
 
     async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
         """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
+        versions = await self._fetch_all_versions_from_wiki(api_key)
+        if versions:
+            logger.debug("Wiki firmware for %s: %s", api_key, versions[0][0])
+            return versions[0][0]
+        return None
+
+    async def _fetch_all_versions_from_wiki(self, api_key: str) -> list[tuple[str, str | None]]:
+        """
+        Fetch all firmware versions from the wiki release history page.
+
+        Only extracts versions that appear in section-heading anchors
+        (e.g. `id="h-01030000-20260303"`) — this excludes version-like
+        numbers mentioned incidentally in release-note text.
+
+        Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
+        """
         wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
         if not wiki_path:
-            return None
+            return []
 
         try:
             url = f"{BAMBU_WIKI_BASE}{wiki_path}"
             response = await self._client.get(url, follow_redirects=True)
-
-            if response.status_code == 200:
-                # Extract version strings (format: XX.XX.XX.XX), first match is the latest
-                versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
-                if versions:
-                    logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
-                    return versions[0]
-            else:
-                logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
-
+            if response.status_code != 200:
+                return []
+
+            # Primary: heading anchor ids like id="h-01030000-20260303"
+            anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-(\d{8})"', response.text)
+            seen: set[str] = set()
+            versions: list[tuple[str, str | None]] = []
+            for a, b, c, d, date in anchor_matches:
+                v = f"{a}.{b}.{c}.{d}"
+                if v in seen:
+                    continue
+                seen.add(v)
+                versions.append((v, date))
+
+            if versions:
+                return versions
+
+            # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)"
+            text_matches = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*\((\d{8})\)", response.text)
+            for v, date in text_matches:
+                if v in seen:
+                    continue
+                seen.add(v)
+                versions.append((v, date))
+            return versions
         except Exception as e:
-            logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
-
-        return None
+            logger.debug("Error fetching wiki firmware list for %s: %s", api_key, e)
+        return []
 
-    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
-        """Fetch firmware info from Bambu Lab's download page (has download URLs)."""
+    async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
+        """Fetch all firmware versions from Bambu Lab's download page (newest first)."""
         build_id = await self._get_build_id()
         if not build_id:
-            return None
+            return []
 
         try:
             url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
@@ -183,20 +214,26 @@ class FirmwareCheckService:
                 printer_map = page_props.get("printerMap", {})
                 printer_data = printer_map.get(api_key, {})
                 versions = printer_data.get("versions", [])
-
-                if versions:
-                    latest = versions[0]
-                    return FirmwareVersion(
-                        version=latest.get("version", ""),
-                        download_url=latest.get("url", ""),
-                        release_notes=latest.get("release_notes_en"),
-                        release_time=latest.get("release_time"),
+                return [
+                    FirmwareVersion(
+                        version=v.get("version", ""),
+                        download_url=v.get("url", ""),
+                        release_notes=v.get("release_notes_en"),
+                        release_time=v.get("release_time"),
                     )
+                    for v in versions
+                    if v.get("version")
+                ]
 
         except Exception as e:
             logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
 
-        return None
+        return []
+
+    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch the latest firmware info from Bambu Lab's download page (has download URLs)."""
+        versions = await self._fetch_all_versions_from_download_page(api_key)
+        return versions[0] if versions else None
 
     async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
         """Fetch firmware version info, using wiki as primary source and download page as fallback."""
@@ -266,6 +303,72 @@ class FirmwareCheckService:
 
         return version
 
+    def _resolve_api_key(self, model: str) -> str | None:
+        """Resolve a model name to its Bambu API key."""
+        model_upper = model.upper().replace(" ", "").replace("-", "")
+        for name, key in MODEL_TO_API_KEY.items():
+            if name.upper().replace(" ", "").replace("-", "") == model_upper:
+                return key
+        return MODEL_TO_API_KEY.get(model)
+
+    @staticmethod
+    def _version_tuple(v: str) -> tuple[int, ...]:
+        parts = [int(x) for x in v.split(".")]
+        while len(parts) < 4:
+            parts.append(0)
+        return tuple(parts)
+
+    async def get_available_versions(self, model: str) -> list[FirmwareVersion]:
+        """
+        Get all announced firmware versions for a model, newest first.
+
+        Merges the wiki release history (list of version strings) with the
+        download page JSON (which provides download URLs + release notes).
+        Versions present only on the wiki have an empty download_url and
+        should be treated as "unavailable" for file-based installation.
+        """
+        api_key = self._resolve_api_key(model)
+        if not api_key:
+            return []
+
+        if api_key in self._versions_list_cache and (time.time() - self._cache_time) < CACHE_TTL:
+            return self._versions_list_cache[api_key]
+
+        wiki_versions = await self._fetch_all_versions_from_wiki(api_key)
+        download_versions = await self._fetch_all_versions_from_download_page(api_key)
+        by_version: dict[str, FirmwareVersion] = {d.version: d for d in download_versions if d.version}
+
+        merged: list[FirmwareVersion] = []
+        seen: set[str] = set()
+        for v, wiki_date in wiki_versions:
+            if v in seen:
+                continue
+            seen.add(v)
+            if v in by_version:
+                merged.append(by_version[v])
+            else:
+                merged.append(FirmwareVersion(version=v, download_url="", release_time=wiki_date))
+        for d in download_versions:
+            if d.version and d.version not in seen:
+                seen.add(d.version)
+                merged.append(d)
+
+        try:
+            merged.sort(key=lambda fv: self._version_tuple(fv.version), reverse=True)
+        except (ValueError, AttributeError):
+            pass
+
+        self._versions_list_cache[api_key] = merged
+        self._cache_time = time.time()
+        return merged
+
+    async def get_version_info(self, model: str, version: str) -> FirmwareVersion | None:
+        """Find a specific version's info (including download URL) for a model."""
+        for v in await self.get_available_versions(model):
+            if v.version == version:
+                return v
+        return None
+
     async def check_for_update(self, model: str, current_version: str) -> dict:
         """
         Check if a firmware update is available for a printer.
@@ -288,17 +391,30 @@ class FirmwareCheckService:
             "latest_version": None,
             "download_url": None,
             "release_notes": None,
+            "available_versions": [],
         }
 
+        available = await self.get_available_versions(model)
+        result["available_versions"] = [
+            {
+                "version": v.version,
+                "download_url": v.download_url or None,
+                "file_available": bool(v.download_url),
+                "release_notes": v.release_notes,
+                "release_time": v.release_time,
+            }
+            for v in available
+        ]
+
         if not current_version:
             return result
 
-        latest = await self.get_latest_version(model)
+        latest = available[0] if available else await self.get_latest_version(model)
         if not latest:
             return result
 
         result["latest_version"] = latest.version
-        result["download_url"] = latest.download_url
+        result["download_url"] = latest.download_url or None
         result["release_notes"] = latest.release_notes
 
         # Compare versions (format: XX.XX.XX.XX)
@@ -340,32 +456,35 @@ class FirmwareCheckService:
         cache_dir.mkdir(parents=True, exist_ok=True)
         return cache_dir
 
-    async def get_firmware_file_info(self, model: str) -> dict | None:
+    async def get_firmware_file_info(self, model: str, version: str | None = None) -> dict | None:
         """
-        Get information about the firmware file for a model.
+        Get information about a firmware file for a model.
 
-        Returns:
-            Dict with download_url, version, filename, and estimated_size (if available)
+        If `version` is provided, returns info for that specific version (must be
+        available on the download page). Otherwise returns info for the latest version.
         """
-        latest = await self.get_latest_version(model)
-        if not latest or not latest.download_url:
+        if version:
+            target = await self.get_version_info(model, version)
+        else:
+            target = await self.get_latest_version(model)
+        if not target or not target.download_url:
             return None
 
-        # Extract filename from URL
-        url_parts = latest.download_url.split("/")
+        url_parts = target.download_url.split("/")
         filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
 
         return {
-            "download_url": latest.download_url,
-            "version": latest.version,
+            "download_url": target.download_url,
+            "version": target.version,
             "filename": filename,
-            "release_notes": latest.release_notes,
+            "release_notes": target.release_notes,
         }
 
     async def download_firmware(
         self,
         model: str,
         progress_callback: Callable[[int, int, str], None] | None = None,
+        version: str | None = None,
     ) -> Path | None:
         """
         Download firmware file for a printer model.
@@ -377,9 +496,12 @@ class FirmwareCheckService:
         Returns:
             Path to downloaded firmware file, or None on failure
         """
-        latest = await self.get_latest_version(model)
+        if version:
+            latest = await self.get_version_info(model, version)
+        else:
+            latest = await self.get_latest_version(model)
         if not latest or not latest.download_url:
-            logger.warning("No firmware download URL available for model: %s", model)
+            logger.warning("No firmware download URL available for model %s version %s", model, version)
             return None
 
         # Extract original filename from URL (must preserve for SD card update)

+ 24 - 9
backend/app/services/firmware_update.py

@@ -79,6 +79,7 @@ class FirmwareUpdateService:
         self,
         printer_id: int,
         db: AsyncSession,
+        target_version: str | None = None,
     ) -> dict:
         """
         Check prerequisites for firmware update.
@@ -105,6 +106,7 @@ class FirmwareUpdateService:
             "update_available": False,
             "current_version": None,
             "latest_version": None,
+            "target_version": target_version,
             "firmware_filename": None,
             "errors": [],
         }
@@ -162,16 +164,23 @@ class FirmwareUpdateService:
                 result["latest_version"] = latest.version
                 result["update_available"] = True  # Assume update needed
 
-        if not result["update_available"]:
-            result["errors"].append("Firmware is already up to date")
-
-        # Get firmware file info
-        file_info = await firmware_service.get_firmware_file_info(model)
+        # Get firmware file info (for target_version if specified, else latest)
+        file_info = await firmware_service.get_firmware_file_info(model, version=target_version)
         if file_info:
             result["firmware_filename"] = file_info["filename"]
             # Estimate size (typical firmware is 50-150MB)
             # We'll get actual size during download
             result["firmware_size"] = 100 * 1024 * 1024  # 100MB estimate
+        elif target_version:
+            # Requested specific version has no download URL
+            result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
+
+        # If a target version is requested, allow proceeding even if it equals or
+        # is older than the current version (explicit downgrade/reinstall).
+        if target_version:
+            result["update_available"] = bool(file_info)
+        elif not result["update_available"]:
+            result["errors"].append("Firmware is already up to date")
 
         # Check space
         if result["sd_card_free_space"] > 0:
@@ -201,6 +210,7 @@ class FirmwareUpdateService:
         self,
         printer_id: int,
         db: AsyncSession,
+        target_version: str | None = None,
     ) -> bool:
         """
         Start the firmware upload process.
@@ -242,6 +252,7 @@ class FirmwareUpdateService:
                 ip_address=printer.ip_address,
                 access_code=printer.access_code,
                 model=model,
+                target_version=target_version,
             )
         )
 
@@ -253,6 +264,7 @@ class FirmwareUpdateService:
         ip_address: str,
         access_code: str,
         model: str,
+        target_version: str | None = None,
     ):
         """Perform the actual firmware download and upload."""
         state = get_upload_state(printer_id)
@@ -265,7 +277,7 @@ class FirmwareUpdateService:
             state.message = "Preparing firmware..."
             await self._broadcast_progress(printer_id, state)
 
-            firmware_path = await firmware_service.download_firmware(model)
+            firmware_path = await firmware_service.download_firmware(model, version=target_version)
 
             if not firmware_path:
                 raise Exception("Failed to download firmware")
@@ -273,9 +285,12 @@ class FirmwareUpdateService:
             state.firmware_filename = firmware_path.name
 
             # Get firmware version for state
-            latest = await firmware_service.get_latest_version(model)
-            if latest:
-                state.firmware_version = latest.version
+            if target_version:
+                state.firmware_version = target_version
+            else:
+                latest = await firmware_service.get_latest_version(model)
+                if latest:
+                    state.firmware_version = latest.version
 
             # Upload to printer (0-100% progress shown here)
             state.status = FirmwareUploadStatus.UPLOADING

+ 130 - 0
backend/tests/unit/test_firmware_versions.py

@@ -0,0 +1,130 @@
+"""
+Unit tests for firmware version listing.
+
+Covers:
+- Wiki-page version extraction is restricted to section-heading anchors
+  (incidental version-like strings in release-note prose must be ignored).
+- Merging wiki + download-page versions produces a single list where
+  wiki-only versions are flagged as unavailable (no download URL).
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.firmware_check import FirmwareCheckService, FirmwareVersion
+
+WIKI_SAMPLE = """
+<h2 id="h-01030000-20260303" class="toc-header">01.03.00.00 (20260303)</h2>
+<p>Released 20260303</p>
+<ul><li>Optimized AMS 2 Pro (requires AMS firmware OTA v02.00.19.47 or newer).</li></ul>
+<h2 id="h-01021000-20260209" class="toc-header">01.02.10.00 (20260209)</h2>
+<p>Bug fixes.</p>
+<h2 id="h-01020200-20251105" class="toc-header">01.02.02.00 (20251105)</h2>
+<p>Some more text referencing 00.00.00.00 incidentally.</p>
+"""
+
+
+@pytest.mark.asyncio
+async def test_wiki_extraction_ignores_prose_version_mentions():
+    """02.00.19.47 appears only in release notes prose — it must not be listed."""
+    svc = FirmwareCheckService()
+    mock_resp = AsyncMock()
+    mock_resp.status_code = 200
+    mock_resp.text = WIKI_SAMPLE
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        versions = await svc._fetch_all_versions_from_wiki("h2d")
+
+    version_strs = [v for v, _ in versions]
+    assert version_strs == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
+    # The AMS firmware mentioned in prose must not leak in:
+    assert "02.00.19.47" not in version_strs
+    assert "00.00.00.00" not in version_strs
+    # Release dates are captured from the anchor id:
+    assert versions[0][1] == "20260303"
+
+
+@pytest.mark.asyncio
+async def test_wiki_extraction_returns_empty_for_unknown_api_key():
+    svc = FirmwareCheckService()
+    assert await svc._fetch_all_versions_from_wiki("no-such-key") == []
+
+
+@pytest.mark.asyncio
+async def test_get_available_versions_merges_sources():
+    """
+    Merged list must include all wiki versions (newest first), populating
+    download URL + notes from the download-page JSON when present, and
+    leaving download_url empty when the file is not published.
+    """
+    svc = FirmwareCheckService()
+
+    wiki = [
+        ("01.03.00.00", "20260303"),
+        ("01.02.10.00", "20260209"),  # wiki-only — should be "unavailable"
+        ("01.02.02.00", "20251105"),
+    ]
+    download = [
+        FirmwareVersion(
+            version="01.03.00.00",
+            download_url="https://cdn.example/1.bin",
+            release_notes="notes 1.3",
+            release_time="2026-03-03",
+        ),
+        FirmwareVersion(
+            version="01.02.02.00",
+            download_url="https://cdn.example/2.bin",
+            release_notes="notes 1.2.2",
+            release_time="2025-11-05",
+        ),
+    ]
+
+    with (
+        patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)),
+        patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)),
+    ):
+        result = await svc.get_available_versions("H2D")
+
+    assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
+    assert result[0].download_url == "https://cdn.example/1.bin"
+    assert result[0].release_notes == "notes 1.3"
+    # Wiki-only version has no download URL → treated as unavailable by callers.
+    assert result[1].download_url == ""
+    assert result[1].release_notes is None
+    assert result[1].release_time == "20260209"
+    assert result[2].download_url == "https://cdn.example/2.bin"
+
+
+@pytest.mark.asyncio
+async def test_get_available_versions_sorts_newest_first():
+    """Merged list must be sorted descending by version tuple regardless of input order."""
+    svc = FirmwareCheckService()
+    wiki = [("01.02.02.00", None)]
+    download = [
+        FirmwareVersion(version="01.03.00.00", download_url="a"),
+        FirmwareVersion(version="01.02.10.00", download_url="b"),
+    ]
+    with (
+        patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)),
+        patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)),
+    ):
+        result = await svc.get_available_versions("H2D")
+    assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
+
+
+@pytest.mark.asyncio
+async def test_check_for_update_includes_available_versions():
+    svc = FirmwareCheckService()
+    available = [
+        FirmwareVersion(version="01.03.00.00", download_url="https://cdn/1.bin", release_notes="x"),
+        FirmwareVersion(version="01.02.10.00", download_url=""),  # unavailable
+    ]
+    with patch.object(svc, "get_available_versions", AsyncMock(return_value=available)):
+        result = await svc.check_for_update("H2D", "01.02.02.00")
+
+    assert result["update_available"] is True
+    assert result["latest_version"] == "01.03.00.00"
+    assert len(result["available_versions"]) == 2
+    assert result["available_versions"][0]["file_available"] is True
+    assert result["available_versions"][1]["file_available"] is False
+    assert result["available_versions"][1]["download_url"] is None

+ 41 - 0
frontend/src/__tests__/utils/firmwareVersion.test.ts

@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { compareFwVersions } from '../../utils/firmwareVersion';
+
+describe('compareFwVersions', () => {
+  it('returns 0 for equal versions', () => {
+    expect(compareFwVersions('01.02.03.04', '01.02.03.04')).toBe(0);
+  });
+
+  it('returns positive when left is newer (major)', () => {
+    expect(compareFwVersions('02.00.00.00', '01.99.99.99')).toBeGreaterThan(0);
+  });
+
+  it('returns negative when left is older (minor)', () => {
+    expect(compareFwVersions('01.02.03.04', '01.03.00.00')).toBeLessThan(0);
+  });
+
+  it('compares patch segments', () => {
+    expect(compareFwVersions('01.02.10.00', '01.02.02.00')).toBeGreaterThan(0);
+  });
+
+  it('compares build segments', () => {
+    expect(compareFwVersions('01.02.03.05', '01.02.03.04')).toBeGreaterThan(0);
+  });
+
+  it('treats missing trailing segments as 0', () => {
+    expect(compareFwVersions('01.02.03', '01.02.03.00')).toBe(0);
+    expect(compareFwVersions('01.02', '01.02.00.00')).toBe(0);
+  });
+
+  it('sorts a list newest-first via descending sort', () => {
+    const versions = ['01.02.02.00', '01.03.00.00', '01.02.10.00'];
+    versions.sort((a, b) => compareFwVersions(b, a));
+    expect(versions).toEqual(['01.03.00.00', '01.02.10.00', '01.02.02.00']);
+  });
+
+  it('handles the issue #568 ordering correctly', () => {
+    // From the issue: current 01.00.05.00 with 01.01.00.00, 01.01.01.00, 01.01.03.00 available.
+    expect(compareFwVersions('01.01.00.00', '01.00.05.00')).toBeGreaterThan(0);
+    expect(compareFwVersions('01.01.03.00', '01.01.01.00')).toBeGreaterThan(0);
+  });
+});

+ 19 - 6
frontend/src/api/client.ts

@@ -5327,6 +5327,14 @@ export const pendingUploadsApi = {
 };
 
 // Firmware API Types
+export interface AvailableFirmwareVersion {
+  version: string;
+  file_available: boolean;
+  download_url: string | null;
+  release_notes: string | null;
+  release_time: string | null;
+}
+
 export interface FirmwareUpdateInfo {
   printer_id: number;
   printer_name: string;
@@ -5336,6 +5344,7 @@ export interface FirmwareUpdateInfo {
   update_available: boolean;
   download_url: string | null;
   release_notes: string | null;
+  available_versions: AvailableFirmwareVersion[];
 }
 
 export interface FirmwareUploadPrepare {
@@ -5347,6 +5356,7 @@ export interface FirmwareUploadPrepare {
   update_available: boolean;
   current_version: string | null;
   latest_version: string | null;
+  target_version: string | null;
   firmware_filename: string | null;
   errors: string[];
 }
@@ -5368,13 +5378,16 @@ export const firmwareApi = {
   checkPrinterUpdate: (printerId: number) =>
     request<FirmwareUpdateInfo>(`/firmware/updates/${printerId}`),
 
-  prepareUpload: (printerId: number) =>
-    request<FirmwareUploadPrepare>(`/firmware/updates/${printerId}/prepare`),
+  prepareUpload: (printerId: number, version?: string) =>
+    request<FirmwareUploadPrepare>(
+      `/firmware/updates/${printerId}/prepare${version ? `?version=${encodeURIComponent(version)}` : ''}`,
+    ),
 
-  startUpload: (printerId: number) =>
-    request<{ started: boolean; message: string }>(`/firmware/updates/${printerId}/upload`, {
-      method: 'POST',
-    }),
+  startUpload: (printerId: number, version?: string) =>
+    request<{ started: boolean; message: string }>(
+      `/firmware/updates/${printerId}/upload${version ? `?version=${encodeURIComponent(version)}` : ''}`,
+      { method: 'POST' },
+    ),
 
   getUploadStatus: (printerId: number) =>
     request<FirmwareUploadStatus>(`/firmware/updates/${printerId}/upload/status`),

+ 7 - 0
frontend/src/i18n/locales/de.ts

@@ -553,6 +553,13 @@ export default {
       uploadFirmware: 'Firmware hochladen',
       uploadFailed: 'Upload fehlgeschlagen: {{error}}',
       uploadedToast: 'Firmware hochgeladen! Starten Sie das Update vom Druckerbildschirm.',
+      availableVersions: 'Verfügbare Versionen',
+      usable: 'Installierbar',
+      unavailable: 'Nicht verfügbar',
+      installed: 'Installiert',
+      newerBadge: 'neuer',
+      olderBadge: 'älter',
+      currentBadge: 'aktuell',
     },
     accessCodePlaceholder: 'Leer lassen, um den aktuellen zu behalten',
     // ROI editor

+ 7 - 0
frontend/src/i18n/locales/en.ts

@@ -553,6 +553,13 @@ export default {
       uploadFirmware: 'Upload Firmware',
       uploadFailed: 'Failed to start upload: {{error}}',
       uploadedToast: 'Firmware uploaded! Trigger update from printer screen.',
+      availableVersions: 'Available versions',
+      usable: 'Usable',
+      unavailable: 'Unavailable',
+      installed: 'Installed',
+      newerBadge: 'newer',
+      olderBadge: 'older',
+      currentBadge: 'current',
     },
     accessCodePlaceholder: 'Leave empty to keep current',
     // ROI editor

+ 99 - 30
frontend/src/pages/PrintersPage.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
+import { compareFwVersions } from '../utils/firmwareVersion';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
@@ -5170,18 +5171,21 @@ function FirmwareUpdateModal({
   const [uploadStatus, setUploadStatus] = useState<FirmwareUploadStatus | null>(null);
   const [isUploading, setIsUploading] = useState(false);
   const [pollInterval, setPollInterval] = useState<NodeJS.Timeout | null>(null);
+  const [selectedVersion, setSelectedVersion] = useState<string | null>(
+    firmwareInfo.update_available ? firmwareInfo.latest_version : null,
+  );
 
-  // Prepare check query (only when update available and user can update)
+  // Prepare check query — runs when a version is selected and user can update
   const { data: prepareInfo, isLoading: isPreparing } = useQuery({
-    queryKey: ['firmwarePrepare', printer.id],
-    queryFn: () => firmwareApi.prepareUpload(printer.id),
+    queryKey: ['firmwarePrepare', printer.id, selectedVersion],
+    queryFn: () => firmwareApi.prepareUpload(printer.id, selectedVersion ?? undefined),
     staleTime: 30000,
-    enabled: firmwareInfo.update_available && canUpdate,
+    enabled: !!selectedVersion && canUpdate && !isUploading,
   });
 
   // Start upload mutation
   const uploadMutation = useMutation({
-    mutationFn: () => firmwareApi.startUpload(printer.id),
+    mutationFn: () => firmwareApi.startUpload(printer.id, selectedVersion ?? undefined),
     onSuccess: () => {
       setIsUploading(true);
       // Start polling for status
@@ -5243,33 +5247,98 @@ function FirmwareUpdateModal({
           </div>
 
           {/* Version Info */}
-          <div className="bg-bambu-dark rounded-lg p-3 mb-4">
-            <div className="flex justify-between items-center text-sm">
-              <span className="text-bambu-gray">{t('printers.firmwareModal.currentVersion')}</span>
-              <span className={`font-mono ${firmwareInfo.update_available ? 'text-white' : 'text-status-ok'}`}>
-                {firmwareInfo.current_version || t('common.unknown')}
-              </span>
-            </div>
-            {firmwareInfo.update_available && (
-              <div className="flex justify-between items-center text-sm mt-1">
-                <span className="text-bambu-gray">{t('printers.firmwareModal.latestVersion')}</span>
-                <span className="text-orange-400 font-mono">{firmwareInfo.latest_version}</span>
-              </div>
-            )}
-            {firmwareInfo.release_notes && (
-              <details className="mt-3 text-sm" open={!firmwareInfo.update_available}>
-                <summary className={`cursor-pointer hover:underline ${firmwareInfo.update_available ? 'text-orange-400' : 'text-status-ok'}`}>
-                  {t('printers.firmwareModal.releaseNotes')}
-                </summary>
-                <div className="mt-2 text-bambu-gray text-xs max-h-40 overflow-y-auto whitespace-pre-wrap">
-                  {firmwareInfo.release_notes}
+          {(() => {
+            const selectedEntry = selectedVersion
+              ? firmwareInfo.available_versions?.find((v) => v.version === selectedVersion)
+              : null;
+            const displayVersion = selectedVersion ?? firmwareInfo.latest_version;
+            const displayNotes = selectedEntry?.release_notes ?? firmwareInfo.release_notes;
+            const showSecondLine = !!displayVersion && displayVersion !== firmwareInfo.current_version;
+            return (
+              <div className="bg-bambu-dark rounded-lg p-3 mb-4">
+                <div className="flex justify-between items-center text-sm">
+                  <span className="text-bambu-gray">{t('printers.firmwareModal.currentVersion')}</span>
+                  <span className={`font-mono ${showSecondLine ? 'text-white' : 'text-status-ok'}`}>
+                    {firmwareInfo.current_version || t('common.unknown')}
+                  </span>
                 </div>
-              </details>
-            )}
-          </div>
+                {showSecondLine && (
+                  <div className="flex justify-between items-center text-sm mt-1">
+                    <span className="text-bambu-gray">{t('printers.firmwareModal.latestVersion')}</span>
+                    <span className="text-orange-400 font-mono">{displayVersion}</span>
+                  </div>
+                )}
+                {displayNotes && (
+                  <details className="mt-3 text-sm" open={!showSecondLine} key={displayVersion ?? 'none'}>
+                    <summary className={`cursor-pointer hover:underline ${showSecondLine ? 'text-orange-400' : 'text-status-ok'}`}>
+                      {t('printers.firmwareModal.releaseNotes')}
+                    </summary>
+                    <div className="mt-2 text-bambu-gray text-xs max-h-40 overflow-y-auto whitespace-pre-wrap">
+                      {displayNotes}
+                    </div>
+                  </details>
+                )}
+              </div>
+            );
+          })()}
+
+          {/* Available versions list */}
+          {firmwareInfo.available_versions && firmwareInfo.available_versions.length > 0 && !isUploading && uploadStatus?.status !== 'complete' && (
+            <div className="mb-4">
+              <div className="text-xs text-bambu-gray mb-2">{t('printers.firmwareModal.availableVersions')}</div>
+              <div className="max-h-56 overflow-y-auto border border-bambu-dark-tertiary rounded-lg divide-y divide-bambu-dark-tertiary">
+                {firmwareInfo.available_versions.map((v) => {
+                  const isCurrent = firmwareInfo.current_version === v.version;
+                  const isSelected = selectedVersion === v.version;
+                  const cmp = firmwareInfo.current_version
+                    ? compareFwVersions(v.version, firmwareInfo.current_version)
+                    : 0;
+                  const relLabel = isCurrent
+                    ? t('printers.firmwareModal.currentBadge')
+                    : cmp > 0
+                      ? t('printers.firmwareModal.newerBadge')
+                      : t('printers.firmwareModal.olderBadge');
+                  const relClass = isCurrent
+                    ? 'text-bambu-gray'
+                    : cmp > 0
+                      ? 'text-orange-400'
+                      : 'text-blue-400';
+                  return (
+                    <button
+                      key={v.version}
+                      type="button"
+                      disabled={!v.file_available || !canUpdate || isCurrent}
+                      onClick={() => setSelectedVersion(v.version)}
+                      className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${
+                        isSelected ? 'bg-orange-500/10' : 'hover:bg-bambu-dark'
+                      } ${!v.file_available || !canUpdate || isCurrent ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
+                    >
+                      <div className="flex items-center gap-2 min-w-0">
+                        <span className="font-mono text-white">{v.version}</span>
+                        <span className={`text-xs ${relClass}`}>{relLabel}</span>
+                      </div>
+                      <span className={`text-xs px-2 py-0.5 rounded-full ${
+                        isCurrent
+                          ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
+                          : v.file_available
+                            ? 'bg-bambu-green/15 text-bambu-green border border-bambu-green/30'
+                            : 'bg-bambu-gray/10 text-bambu-gray border border-bambu-gray/30'
+                      }`}>
+                        {isCurrent
+                          ? t('printers.firmwareModal.installed')
+                          : v.file_available
+                          ? t('printers.firmwareModal.usable')
+                          : t('printers.firmwareModal.unavailable')}
+                      </span>
+                    </button>
+                  );
+                })}
+              </div>
+            </div>
+          )}
 
-          {/* Status / Progress (only when update available) */}
-          {!firmwareInfo.update_available ? null : isPreparing ? (
+          {/* Status / Progress (only when a version is selected) */}
+          {!selectedVersion ? null : isPreparing ? (
             <div className="flex items-center gap-2 text-bambu-gray text-sm mb-4">
               <Loader2 className="w-4 h-4 animate-spin" />
               {t('printers.firmwareModal.checkingPrereqs')}

+ 16 - 0
frontend/src/utils/firmwareVersion.ts

@@ -0,0 +1,16 @@
+/**
+ * Compare two Bambu Lab firmware version strings (format: "XX.XX.XX.XX").
+ *
+ * Returns a negative number if `a` < `b`, zero if equal, positive if `a` > `b`.
+ * Missing trailing segments are treated as 0.
+ */
+export function compareFwVersions(a: string, b: string): number {
+  const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
+  const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
+  while (pa.length < 4) pa.push(0);
+  while (pb.length < 4) pb.push(0);
+  for (let i = 0; i < 4; i++) {
+    if (pa[i] !== pb[i]) return pa[i] - pb[i];
+  }
+  return 0;
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DLtHRwHs.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Df0jWYPb.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-bpuJrR8P.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BgsTQr_b.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-bpuJrR8P.css">
+    <script type="module" crossorigin src="/assets/index-DLtHRwHs.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Df0jWYPb.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff