mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(firmware): keep download-URL resolution working when bambulab.com 403s (#1350)
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
misleading error "Firmware file is not available from Bambu Lab" while the
logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
on the reporter's network, and the buildId was cached in memory only, so a
single 403 broke download-URL resolution for the rest of the session.
- Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
so the request stops tripping Cloudflare's "bare scraper" signal.
- Persist the buildId to <data_dir>/firmware/build_id.json so a transient
403 or a backend restart can't wipe a previously-valid buildId.
- Add a download_page_unreachable flag and use it in the prepare-update flow
to render an honest error ("page unreachable from this network — try later
or download manually from bambulab.com") instead of implying Bambu doesn't
have the file.
- Retry the per-model JSON once when a cached buildId returns 404 (page
rebuild), give up gracefully on 403 without churning.
This commit is contained in:
@@ -17,6 +17,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
|
||||
|
||||
### Fixed
|
||||
- **Firmware update dialog now survives Cloudflare-blocked or transient outages on `bambulab.com`** ([#1350](https://github.com/maziggy/bambuddy/issues/1350), reported by @K1ngJony) — User's X1C on 01.10.00.00 saw "01.11.02.00 newer · Unavailable" plus the error "Firmware file for 01.11.02.00 is not available from Bambu Lab", and the logs showed repeated `Failed to get Bambu Lab page: 403` warnings. Two problems stacked: (1) `https://bambulab.com/en/support/firmware-download/all` (the page Bambuddy scrapes to extract the Next.js `buildId` used to fetch per-model JSON with download URLs) was returning 403 from the reporter's network — Cloudflare bot protection on bambulab.com is stricter than on the wiki and, prior to the 2026-05-12 compliance audit, the firmware-check service still claimed to be Chrome 120 via UA spoofing. The UA was updated to honest `Bambuddy/1.0` in that audit but `Accept` / `Accept-Language` headers were never sent, so the request still tripped the "bare Python client" signal. (2) The `buildId` was cached in-memory only (1 h TTL), so every backend restart forced a fresh page fetch — meaning the first 403 from the user's network permanently broke download-URL resolution for that session even though the previous run had a perfectly valid buildId. **Fix in [`backend/app/services/firmware_check.py`](backend/app/services/firmware_check.py):** (a) the httpx client now sends `Accept: text/html,application/json,*/*;q=0.8` and `Accept-Language: en-US,en;q=0.9` alongside the existing honest `Bambuddy/1.0` UA — both headers any normal client sends, no impersonation. (b) `_get_build_id()` gained a disk-cache layer at `<data_dir>/firmware/build_id.json`: successful fetches persist `{build_id, fetched_at}` to disk; the in-memory cache (fresh path, 1 h TTL) is checked first, then the disk cache seeds the in-memory slot on cold start, then the live fetch tries to refresh. On 403 or network error, we keep the cached buildId and set a new `download_page_unreachable` flag so callers can render an honest error. (c) `_fetch_all_versions_from_download_page` now retries **once** when a cached buildId returns 404 (Bambu rebuilt the page → invalidate + refetch + retry); on 403 it sets the unreachable flag and gives up gracefully without churning. **Better error message in [`backend/app/services/firmware_update.py`](backend/app/services/firmware_update.py):** when a wiki-listed version has no download URL because `download_page_unreachable` is true, the dialog now says `"Could not reach Bambu Lab's firmware download page to fetch the file URL for X. Version is listed on the Bambu wiki but the download endpoint is unreachable from this network. Try again later, or download the firmware manually from bambulab.com and copy it to the printer's SD card."` instead of the misleading `"Firmware file for X is not available from Bambu Lab"` (which implied Bambu didn't have the file, when actually we just couldn't reach Bambu). Version genuinely not in the catalog still gets the original message. **Regression tests** in [`backend/tests/unit/test_firmware_versions.py`](backend/tests/unit/test_firmware_versions.py): `test_client_headers_identify_honestly_and_send_browser_accept` pins UA + Accept headers, `test_build_id_is_persisted_to_disk` confirms the disk write on success, `test_build_id_falls_back_to_disk_on_403` reproduces the reporter's 403 with a pre-seeded disk cache, `test_download_page_unreachable_flag_set_on_403_json` covers the per-model JSON endpoint 403 path, `test_download_page_retries_once_when_buildid_stale` proves the 404 retry. All 12 firmware tests + ruff clean.
|
||||
|
||||
- **Subtype dropdown on the Add/Edit Spool form now offers `CF` (carbon fiber) and `GF` (glass fiber)** ([#1345](https://github.com/maziggy/bambuddy/issues/1345), reported by @maziggy) — The Subtype dropdown in [`frontend/src/components/spool-form/FilamentSection.tsx`](frontend/src/components/spool-form/FilamentSection.tsx) is populated from the `KNOWN_VARIANTS` array in [`frontend/src/components/spool-form/constants.ts`](frontend/src/components/spool-form/constants.ts). `CF` and `GF` were missing, so a user adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF flow (the same shape Bambu's "PETG HF" already used) couldn't find the subtype in the list and had to type it freehand into the "create new" tail. Added both — `CF` to match `PETG-CF` / `PLA-CF` / `ASA-CF` / `PA-CF`, and `GF` as the natural pair for `ABS-GF` / `PA6-GF`. `parsePresetName` in [`spool-form/utils.ts`](frontend/src/components/spool-form/utils.ts) is unaffected: its materials list is iterated longest-first, so a cloud preset like `Bambu PETG-CF Black` still resolves to material=`PETG-CF` with empty afterMaterial (the variant loop runs on `""` and finds nothing — no accidental Material=PETG / Subtype=CF rewrite). Frontend build clean.
|
||||
|
||||
- **Spool-assignment dialog stacks correctly: the material-mismatch confirmation appears above its parent, and dashboard filament hover popovers no longer get covered by sibling printer cards** ([#1336](https://github.com/maziggy/bambuddy/issues/1336) follow-up, mismatch case reported by @RosdasHH) — Two stacking-context regressions surfaced after the original z-50 → z-[100] bump on `AssignSpoolModal` landed. **(1) Material-mismatch ConfirmModal hidden behind its parent.** Assigning a spool with a different material to the one configured on the slot opens a yellow warning ConfirmModal from inside `AssignSpoolModal`. ConfirmModal's overlay was hardcoded to `z-50` in its wrapper at [`frontend/src/components/ConfirmModal.tsx`](frontend/src/components/ConfirmModal.tsx), so once the parent moved to `z-[100]` the child sat behind it — the user clicked Assign, saw the parent dim slightly, and nothing visible to confirm. Added an optional `overlayZIndex?: string` prop to `ConfirmModal` (defaults to `z-50` so all 82 other call sites are untouched), and the mismatch site at [`AssignSpoolModal.tsx:584`](frontend/src/components/AssignSpoolModal.tsx) passes `overlayZIndex="z-[110]"` so the warning sits above its parent. **(2) `FilamentHoverCard` / `EmptySlotHoverCard` covered by neighbouring printer cards.** Hovering an AMS slot on the dashboard opens a "Jade White · Bambu PETG HF · K Factor 0.024 · 87% · Open in Inventory / Configure" popover. The popover was using `position: absolute` with `z-[60]` inside its trigger — but each printer card on the dashboard creates its own stacking context (any `filter: drop-shadow` / `transform` / positioned-with-z descendant is enough), and `z-index` does not cross stacking-context boundaries: the next card in DOM order always wins regardless of how high the inner z-index goes. Visible as a "Jade White" tooltip getting half-eaten by the AMS-C tile column on the right neighbour card. Fixed by **portaling both hover cards to `document.body`** ([`FilamentHoverCard.tsx`](frontend/src/components/FilamentHoverCard.tsx) via `createPortal` from `react-dom`) with `position: fixed` and screen-space coordinates computed from `triggerRef.current.getBoundingClientRect()`. Coords are recomputed on visibility change, on `scroll` (capture phase) and on `resize` so the popover tracks the trigger when the viewport moves; a `requestAnimationFrame` re-measure after the initial paint avoids a one-frame flicker before the card has its rendered dimensions. Hover handlers wired on both the trigger AND the portaled card so moving the cursor from the slot tile onto the popover doesn't auto-dismiss it after 100 ms. The smart top/bottom placement logic (flips to below the trigger when there's not enough headroom above the fixed 56 px header) is preserved, as is the arrow pointer that points back at the slot. `z-[60]` stays — but it's now global because the popover lives at the root of the DOM, so it always beats dashboard widgets without conflicting with full-screen modals at `z-[100]`. All 20 `FilamentHoverCard`, 17 `ConfirmModal`, and 13 `AssignSpoolModal` tests pass; frontend build clean.
|
||||
|
||||
@@ -6,6 +6,7 @@ download page. The wiki is used as the primary version source (always up-to-date
|
||||
while the download page provides firmware file URLs for offline updates.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -116,6 +117,7 @@ class FirmwareCheckService:
|
||||
def __init__(self):
|
||||
self._build_id: str | None = None
|
||||
self._build_id_time: float = 0
|
||||
self._download_page_unreachable: bool = False
|
||||
self._version_cache: dict[str, FirmwareVersion] = {}
|
||||
self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
|
||||
self._cache_time: float = 0
|
||||
@@ -126,31 +128,103 @@ class FirmwareCheckService:
|
||||
# Lab firmware wiki — verified 2026-05-12 that the wiki serves
|
||||
# this UA identically to a Chrome UA (same HTML response shape).
|
||||
# No browser impersonation needed for read-only public pages.
|
||||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
|
||||
"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
||||
# Some Cloudflare bot rules on bambulab.com 403 requests with a
|
||||
# bare UA but no browser-like Accept headers (seen on AU IPs in
|
||||
# #1350). Sending normal Accept hints removes that signal while
|
||||
# staying honestly identified via the UA above.
|
||||
"Accept": "text/html,application/json,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
|
||||
def _build_id_cache_path(self) -> Path:
|
||||
cache_dir = _data_dir / "firmware"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / "build_id.json"
|
||||
|
||||
def _load_build_id_from_disk(self) -> tuple[str | None, float]:
|
||||
"""Load the last-known buildId from disk, returning (build_id, fetched_at)."""
|
||||
path = self._build_id_cache_path()
|
||||
try:
|
||||
if not path.exists():
|
||||
return None, 0.0
|
||||
data = json.loads(path.read_text())
|
||||
build_id = data.get("build_id")
|
||||
fetched_at = float(data.get("fetched_at", 0))
|
||||
if isinstance(build_id, str) and build_id:
|
||||
return build_id, fetched_at
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
logger.debug("Could not read cached buildId: %s", e)
|
||||
return None, 0.0
|
||||
|
||||
def _save_build_id_to_disk(self, build_id: str) -> None:
|
||||
try:
|
||||
self._build_id_cache_path().write_text(json.dumps({"build_id": build_id, "fetched_at": time.time()}))
|
||||
except OSError as e:
|
||||
logger.debug("Could not persist buildId: %s", e)
|
||||
|
||||
async def _get_build_id(self) -> str | None:
|
||||
"""Fetch the Next.js build ID from Bambu Lab's firmware page."""
|
||||
# Use cached build ID if still valid (cache for 1 hour)
|
||||
"""Fetch the Next.js build ID from Bambu Lab's firmware page.
|
||||
|
||||
Cache layers (fresh → stale → none):
|
||||
1. In-memory (1 hour TTL) — fast path for repeated checks in a session
|
||||
2. Disk-cached buildId (any age) — survives restarts, lets us recover
|
||||
from upstream Cloudflare 403s. The buildId is treated as
|
||||
"probably still valid" because Bambu rebuilds the page only every
|
||||
few weeks; if the JSON fetch later fails, the caller falls back.
|
||||
3. Live fetch from bambulab.com — only when both caches miss
|
||||
"""
|
||||
# 1. In-memory cache (fresh)
|
||||
if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
|
||||
return self._build_id
|
||||
|
||||
# 2. Disk cache: load if we don't have one in memory yet (first call
|
||||
# after restart). We still try the live fetch below to refresh.
|
||||
if not self._build_id:
|
||||
disk_id, disk_time = self._load_build_id_from_disk()
|
||||
if disk_id:
|
||||
self._build_id = disk_id
|
||||
self._build_id_time = disk_time
|
||||
|
||||
# 3. Live fetch
|
||||
try:
|
||||
response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
|
||||
if response.status_code == 200:
|
||||
# Extract buildId from the page
|
||||
match = re.search(r'"buildId":"([^"]+)"', response.text)
|
||||
if match:
|
||||
self._build_id = match.group(1)
|
||||
new_build_id = match.group(1)
|
||||
if new_build_id != self._build_id:
|
||||
logger.info("Got Bambu Lab build ID: %s", new_build_id)
|
||||
self._build_id = new_build_id
|
||||
self._build_id_time = time.time()
|
||||
logger.info("Got Bambu Lab build ID: %s", self._build_id)
|
||||
self._download_page_unreachable = False
|
||||
self._save_build_id_to_disk(new_build_id)
|
||||
return self._build_id
|
||||
logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
|
||||
else:
|
||||
# 403/5xx — keep stale cached buildId if we have one (#1350).
|
||||
logger.warning(
|
||||
"Failed to get Bambu Lab page: %s (will try cached buildId if available)",
|
||||
response.status_code,
|
||||
)
|
||||
self._download_page_unreachable = True
|
||||
except Exception as e:
|
||||
logger.error("Error fetching Bambu Lab build ID: %s", e)
|
||||
self._download_page_unreachable = True
|
||||
|
||||
return self._build_id # Return cached value if available
|
||||
# Return whatever we have — even a stale buildId beats nothing.
|
||||
return self._build_id
|
||||
|
||||
@property
|
||||
def download_page_unreachable(self) -> bool:
|
||||
"""True if the most recent attempt to reach bambulab.com firmware page failed.
|
||||
|
||||
Used by callers (e.g. the firmware update prepare flow) to render a
|
||||
clearer error message when a wiki-listed version has no download URL
|
||||
because we couldn't reach Bambu Lab, vs the version genuinely not
|
||||
being on the catalog (#1350).
|
||||
"""
|
||||
return self._download_page_unreachable
|
||||
|
||||
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."""
|
||||
@@ -216,34 +290,62 @@ class FirmwareCheckService:
|
||||
return []
|
||||
|
||||
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)."""
|
||||
"""Fetch all firmware versions from Bambu Lab's download page (newest first).
|
||||
|
||||
If we have a stale (disk-cached) buildId and it returns 404 (Bambu
|
||||
rebuilt the page), retry once with a fresh fetch — this only kicks in
|
||||
when the in-memory cache thinks it's still valid but the upstream has
|
||||
moved on.
|
||||
"""
|
||||
build_id = await self._get_build_id()
|
||||
if not build_id:
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
|
||||
response = await self._client.get(url)
|
||||
for attempt in range(2):
|
||||
try:
|
||||
url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
|
||||
response = await self._client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
page_props = data.get("pageProps", {})
|
||||
printer_map = page_props.get("printerMap", {})
|
||||
printer_data = printer_map.get(api_key, {})
|
||||
versions = printer_data.get("versions", [])
|
||||
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")
|
||||
]
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
page_props = data.get("pageProps", {})
|
||||
printer_map = page_props.get("printerMap", {})
|
||||
printer_data = printer_map.get(api_key, {})
|
||||
versions = printer_data.get("versions", [])
|
||||
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)
|
||||
# 404 with cached buildId → Bambu rebuilt the page; invalidate
|
||||
# and retry once. Other status codes (403, 5xx) are upstream
|
||||
# blocks — don't churn.
|
||||
if response.status_code == 404 and attempt == 0:
|
||||
logger.info("Cached Bambu buildId stale (404), refreshing")
|
||||
self._build_id = None
|
||||
self._build_id_time = 0
|
||||
build_id = await self._get_build_id()
|
||||
if not build_id:
|
||||
return []
|
||||
continue
|
||||
|
||||
# 403 from the JSON endpoint is the same Cloudflare block
|
||||
# signal as on the index page (#1350).
|
||||
if response.status_code == 403:
|
||||
self._download_page_unreachable = True
|
||||
|
||||
logger.debug("Download-page JSON for %s returned status %s", api_key, response.status_code)
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@@ -172,8 +172,20 @@ class FirmwareUpdateService:
|
||||
# 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")
|
||||
# Requested specific version has no download URL. Distinguish
|
||||
# "Bambu doesn't list this file" from "we couldn't reach Bambu's
|
||||
# download page" (Cloudflare 403 reported in #1350) so users in
|
||||
# affected regions get an actionable error instead of believing
|
||||
# the firmware doesn't exist.
|
||||
if firmware_service.download_page_unreachable:
|
||||
result["errors"].append(
|
||||
f"Could not reach Bambu Lab's firmware download page to fetch the file URL for "
|
||||
f"{target_version}. Version is listed on the Bambu wiki but the download endpoint "
|
||||
f"is unreachable from this network. Try again later, or download the firmware "
|
||||
f"manually from bambulab.com and copy it to the printer's SD card."
|
||||
)
|
||||
else:
|
||||
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).
|
||||
|
||||
@@ -6,9 +6,11 @@ Covers:
|
||||
(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).
|
||||
- buildId disk-persistence + 403 fallback for #1350.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -159,6 +161,137 @@ async def test_get_available_versions_sorts_newest_first():
|
||||
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_client_headers_identify_honestly_and_send_browser_accept():
|
||||
"""
|
||||
The httpx client must identify as Bambuddy (no Chrome impersonation) and
|
||||
must send Accept + Accept-Language so Cloudflare on bambulab.com doesn't
|
||||
403 us for looking like a bare scraper (#1350).
|
||||
"""
|
||||
svc = FirmwareCheckService()
|
||||
headers = svc._client.headers
|
||||
assert headers["User-Agent"].startswith("Bambuddy/")
|
||||
assert "Chrome" not in headers["User-Agent"]
|
||||
assert "Accept" in headers
|
||||
assert "Accept-Language" in headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
|
||||
"""Successful buildId fetch writes to disk so it survives restart (#1350)."""
|
||||
monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
|
||||
|
||||
svc = FirmwareCheckService()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
|
||||
|
||||
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
|
||||
build_id = await svc._get_build_id()
|
||||
|
||||
assert build_id == "abc123xyz"
|
||||
cache_file = tmp_path / "firmware" / "build_id.json"
|
||||
assert cache_file.exists()
|
||||
data = json.loads(cache_file.read_text())
|
||||
assert data["build_id"] == "abc123xyz"
|
||||
assert data["fetched_at"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
|
||||
"""
|
||||
When bambulab.com 403s (Cloudflare block reported in #1350) we must
|
||||
fall back to the disk-cached buildId from the previous successful fetch.
|
||||
Without this the user's screenshots happen: wiki version is detected
|
||||
but the download URL stays empty forever.
|
||||
"""
|
||||
monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
|
||||
|
||||
# Pre-seed a previously-saved buildId
|
||||
cache_dir = tmp_path / "firmware"
|
||||
cache_dir.mkdir(parents=True)
|
||||
(cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0}))
|
||||
|
||||
svc = FirmwareCheckService()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = "<html>Access denied</html>"
|
||||
|
||||
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
|
||||
build_id = await svc._get_build_id()
|
||||
|
||||
assert build_id == "cached_id_42"
|
||||
assert svc.download_page_unreachable is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_page_unreachable_flag_set_on_403_json(tmp_path, monkeypatch):
|
||||
"""A 403 on the per-model JSON endpoint also marks the page unreachable."""
|
||||
monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
|
||||
|
||||
svc = FirmwareCheckService()
|
||||
svc._build_id = "stale_id"
|
||||
svc._build_id_time = 9999999999.0 # never expires for this test
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = "Forbidden"
|
||||
|
||||
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
|
||||
result = await svc._fetch_all_versions_from_download_page("x1")
|
||||
|
||||
assert result == []
|
||||
assert svc.download_page_unreachable is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypatch):
|
||||
"""
|
||||
If the cached buildId returns 404 (Bambu rebuilt the page), refresh the
|
||||
buildId once and retry — but don't churn on repeated failures.
|
||||
"""
|
||||
monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
|
||||
|
||||
svc = FirmwareCheckService()
|
||||
svc._build_id = "stale_id"
|
||||
svc._build_id_time = 9999999999.0
|
||||
|
||||
# First call (with stale buildId) → 404
|
||||
# Second call (with fresh buildId after refresh) → 200 with versions
|
||||
stale_resp = MagicMock(status_code=404, text="not found")
|
||||
fresh_resp = MagicMock(status_code=200)
|
||||
fresh_resp.json = MagicMock(
|
||||
return_value={
|
||||
"pageProps": {
|
||||
"printerMap": {
|
||||
"x1": {
|
||||
"versions": [
|
||||
{
|
||||
"version": "01.11.02.00",
|
||||
"url": "https://cdn/fw.bin",
|
||||
"release_notes_en": "n",
|
||||
"release_time": "2025-12-10",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
# Page fetch for the buildId refresh
|
||||
page_resp = MagicMock(status_code=200)
|
||||
page_resp.text = 'foo "buildId":"fresh_id" bar'
|
||||
|
||||
# Sequence: stale 404 → page refresh 200 → fresh 200
|
||||
get = AsyncMock(side_effect=[stale_resp, page_resp, fresh_resp])
|
||||
with patch.object(svc._client, "get", get):
|
||||
result = await svc._fetch_all_versions_from_download_page("x1")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].version == "01.11.02.00"
|
||||
assert svc._build_id == "fresh_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_update_includes_available_versions():
|
||||
svc = FirmwareCheckService()
|
||||
|
||||
Reference in New Issue
Block a user