Parcourir la source

Stop firmware lookups when "Check printer firmware" is off

maziggy il y a 1 jour
Parent
commit
0165dafd29

+ 1 - 0
CHANGELOG.md

@@ -59,6 +59,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Turning off "Check printer firmware" stops every firmware lookup** — With the setting off, opening the Printers page still asked bambulab.com once per printer: the page decided before its settings had loaded, and the server answered the request without looking at the setting. The page now waits for the setting, and the server enforces it as well, for both the per-printer and the all-printers check. The lookups could also take a long time on a fresh start (30-second timeouts with fallbacks); on a connection without HTTP/2, which includes a plain `http://` address on the local network, they could hold the browser's few connections and delay the rest of the page.
 - **The stream overlay's camera recovers from a frozen picture on its own (#3205, reported and contributed by @adamspicedev in #3213)** — When a printer stopped sending frames, Bambuddy ended the camera stream, but the overlay kept showing the last frame and only reconnected if the browser reported an image error, which it doesn't in that case. An unattended overlay in OBS or Meld Studio then stayed frozen until its browser source was refreshed, while the printers page, which checks the camera status, reconnected by itself. The overlay now renews its camera connection every 60 seconds, and still retries 3 seconds after an image error. A renewal joins the camera stream Bambuddy already has open for that printer and shows the latest frame at once, so a working picture doesn't flicker. The overlay URL, token, FPS and rotation are kept. With `camera=false` the overlay makes no camera requests.
 - **Stream overlay and Cam Wall links keep working after a reload (#3204, reported and contributed by @adamspicedev in #3212)** — Opening an `/overlay/…?token=…` or `/camwall?token=…` link treated the token as a login: Bambuddy removed it from the address bar and tried to sign in with it, which a streaming token can't do. The page worked the first time, but a reload (or an OBS or Meld browser source restarting) had no token left and stayed on **Loading…**. In a browser that was already signed in, the failed attempt also signed the user out. The token now stays in the link and is used only by the overlay or Cam Wall, and an existing login is left alone. SpoolBuddy kiosk links still sign in with their API key as before.
 - **The File Manager reaches the bottom of the window again (#3215, reported by @koder-guy)** — On desktop the File Manager and the G-code viewer were sized as if a 64px top bar sat above them, but that bar only exists in the mobile layout, so both pages stopped 64px short and left an empty band under the file browser. They now fill the full window height.

+ 39 - 5
backend/app/api/routes/firmware.py

@@ -16,6 +16,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.services.firmware_check import get_firmware_service
 from backend.app.services.firmware_update import (
@@ -70,6 +71,37 @@ class LatestFirmwareInfo(BaseModel):
     release_notes: str | None = None
 
 
+async def _checks_enabled(db: AsyncSession) -> bool:
+    """The "Check printer firmware" setting (default on).
+
+    Enforced here and not only in the UI: the printers page asks before its
+    settings have loaded, so a check switched off still went out to bambulab.com
+    once per printer on every page load.
+    """
+    value = (
+        await db.execute(select(Settings.value).where(Settings.key == "check_printer_firmware"))
+    ).scalar_one_or_none()
+    return (value or "true").lower() != "false"
+
+
+def _current_version(printer_id: int) -> str | None:
+    mqtt_client = printer_manager.get_client(printer_id)
+    if mqtt_client and mqtt_client.state:
+        return mqtt_client.state.firmware_version
+    return None
+
+
+def _not_checked(printer: Printer) -> FirmwareUpdateInfo:
+    return FirmwareUpdateInfo(
+        printer_id=printer.id,
+        printer_name=printer.name,
+        model=printer.model or "Unknown",
+        current_version=_current_version(printer.id),
+        latest_version=None,
+        update_available=False,
+    )
+
+
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 async def check_firmware_updates(
     db: AsyncSession = Depends(get_db),
@@ -90,6 +122,9 @@ async def check_firmware_updates(
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     printers = result.scalars().all()
 
+    if not await _checks_enabled(db):
+        return FirmwareUpdatesResponse(updates=[_not_checked(p) for p in printers], updates_available=0)
+
     updates = []
     updates_available = 0
 
@@ -142,11 +177,10 @@ async def check_printer_firmware(
     if not printer:
         raise HTTPException(status_code=404, detail="Printer not found")
 
-    # Get current firmware version from MQTT state
-    current_version = None
-    mqtt_client = printer_manager.get_client(printer.id)
-    if mqtt_client and mqtt_client.state:
-        current_version = mqtt_client.state.firmware_version
+    if not await _checks_enabled(db):
+        return _not_checked(printer)
+
+    current_version = _current_version(printer.id)
 
     # Check for update
     model = printer.model or "Unknown"

+ 78 - 0
backend/tests/integration/test_firmware_check_setting.py

@@ -0,0 +1,78 @@
+"""The "Check printer firmware" setting is enforced by the API, not only the UI.
+
+The printers page asked before its settings had loaded, so with the check
+switched off Bambuddy still went out to bambulab.com once per printer on every
+page load. Off has to mean no request at all.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.settings import Settings
+
+UPDATE = {
+    "update_available": True,
+    "latest_version": "01.09.00.00",
+    "download_url": "https://example.com/fw.bin",
+    "release_notes": None,
+    "available_versions": [],
+}
+
+
+def _service() -> MagicMock:
+    service = MagicMock()
+    service.check_for_update = AsyncMock(return_value=UPDATE)
+    return service
+
+
+async def _switch_off(db) -> None:
+    db.add(Settings(key="check_printer_firmware", value="false"))
+    await db.commit()
+
+
+@pytest.mark.integration
+class TestFirmwareCheckSetting:
+    @pytest.mark.asyncio
+    async def test_on_by_default(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        service = _service()
+        with patch("backend.app.api.routes.firmware.get_firmware_service", return_value=service):
+            r = await async_client.get(f"/api/v1/firmware/updates/{printer.id}")
+        assert r.status_code == 200, r.text
+        assert r.json()["update_available"] is True
+        service.check_for_update.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_off_asks_nobody(self, async_client: AsyncClient, db_session, printer_factory):
+        printer = await printer_factory(name="Garage")
+        await _switch_off(db_session)
+        service = _service()
+        with patch("backend.app.api.routes.firmware.get_firmware_service", return_value=service):
+            r = await async_client.get(f"/api/v1/firmware/updates/{printer.id}")
+        assert r.status_code == 200, r.text
+        body = r.json()
+        assert body["printer_id"] == printer.id and body["printer_name"] == "Garage"
+        assert body["update_available"] is False and body["latest_version"] is None
+        service.check_for_update.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_off_still_404s_an_unknown_printer(self, async_client: AsyncClient, db_session):
+        await _switch_off(db_session)
+        assert (await async_client.get("/api/v1/firmware/updates/9999")).status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_off_covers_the_all_printers_check(self, async_client: AsyncClient, db_session, printer_factory):
+        await printer_factory()
+        await printer_factory()
+        await _switch_off(db_session)
+        service = _service()
+        with patch("backend.app.api.routes.firmware.get_firmware_service", return_value=service):
+            r = await async_client.get("/api/v1/firmware/updates")
+        assert r.status_code == 200, r.text
+        body = r.json()
+        assert body["updates_available"] == 0
+        assert len(body["updates"]) == 2
+        assert all(u["update_available"] is False for u in body["updates"])
+        service.check_for_update.assert_not_called()

+ 30 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -1036,6 +1036,36 @@ describe('PrintersPage', () => {
       expect(screen.queryByText('01.08.00.00')).not.toBeInTheDocument();
     });
 
+    it('never asks for firmware when the check is off, not even before preferences load', async () => {
+      // The page used to decide on `undefined` while the preferences were still
+      // loading, so a check switched off still went out once per printer.
+      let firmwareRequests = 0;
+      let releasePreferences: () => void = () => {};
+      const preferencesHeld = new Promise<void>((resolve) => {
+        releasePreferences = resolve;
+      });
+      server.use(
+        http.get('/api/v1/firmware/updates/:id', () => {
+          firmwareRequests += 1;
+          return HttpResponse.json(firmwareUpdateAvailable);
+        }),
+        http.get('/api/v1/settings/ui-preferences', async () => {
+          await preferencesHeld;
+          return HttpResponse.json({ check_printer_firmware: false });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+      releasePreferences();
+      // Long enough for a query that was going to fire to have fired.
+      await new Promise((resolve) => setTimeout(resolve, 100));
+      expect(firmwareRequests).toBe(0);
+    });
+
     it('hides badge when API has no firmware data for the model', async () => {
       const firmwareNoData = {
         printer_id: 1,

+ 5 - 2
frontend/src/pages/PrintersPage.tsx

@@ -8915,6 +8915,9 @@ export function PrintersPage() {
     queryKey: ['ui-preferences'],
     queryFn: api.getUiPreferences,
   });
+  // Only once the preferences are in: deciding on `undefined` meant a check
+  // switched off still went out once per printer on every page load.
+  const firmwareChecksOn = settings !== undefined && settings.check_printer_firmware !== false;
 
   // Parse user-configured temperature/fan presets once, with defensive fallback
   // to built-in defaults on parse failure (validators on the backend already
@@ -9969,7 +9972,7 @@ export function PrintersPage() {
                       cameraViewMode={cameraViewMode}
                       onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                       onSelectCameraViewMode={selectCameraViewMode}
-                      checkPrinterFirmware={settings?.check_printer_firmware !== false}
+                      checkPrinterFirmware={firmwareChecksOn}
                       dryingPresets={effectiveDryingPresets}
                       nozzleTempPresets={effectiveNozzleTempPresets}
                       bedTempPresets={effectiveBedTempPresets}
@@ -10022,7 +10025,7 @@ export function PrintersPage() {
               cameraViewMode={cameraViewMode}
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
               onSelectCameraViewMode={selectCameraViewMode}
-              checkPrinterFirmware={settings?.check_printer_firmware !== false}
+              checkPrinterFirmware={firmwareChecksOn}
               dryingPresets={effectiveDryingPresets}
               nozzleTempPresets={effectiveNozzleTempPresets}
               bedTempPresets={effectiveBedTempPresets}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ImagePreviewModal-DRfunMYn.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 1
static/assets/PdfPreviewModal-C4ar5SVt.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Dt2h99YI.js


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
static/assets/index-7qfiHY5t.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/pdf-DG3KNKaP.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bc3Y2a4o.js"></script>
+    <script type="module" crossorigin src="/assets/index-7qfiHY5t.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-CyW_3l56.css">
   </head>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff