Просмотр исходного кода

feat(auth): SSO autologin + disable local username/password login (#1589)

      Adds a global local_login_enabled setting plus a per-provider
      is_autologin flag on OIDCProvider so operators who run their own SSO
      enabled, or if the calling admin has no UserOIDCLink — either would
      lock everyone out. App-layer invariant: at most one provider can carry
      is_autologin; setting it on one clears it on every other.

      /auth/advanced-auth/status surfaces both new fields so the LoginPage
      decides UI in one query. The env-var bypass flips the reported
      local_login_enabled back to true so the SPA matches what the route
      will accept.
maziggy 2 месяцев назад
Родитель
Сommit
549d3216d4
45 измененных файлов с 2281 добавлено и 94 удалено
  1. 11 0
      .env.example
  2. 0 0
      CHANGELOG.md
  3. 3 1
      README.md
  4. 88 3
      backend/app/api/routes/auth.py
  5. 17 1
      backend/app/api/routes/mfa.py
  6. 38 1
      backend/app/api/routes/printers.py
  7. 33 3
      backend/app/api/routes/settings.py
  8. 12 2
      backend/app/api/routes/websocket.py
  9. 7 0
      backend/app/core/database.py
  10. 12 2
      backend/app/main.py
  11. 7 0
      backend/app/models/oidc_provider.py
  12. 3 0
      backend/app/schemas/auth.py
  13. 5 0
      backend/app/schemas/printer.py
  14. 27 0
      backend/app/schemas/settings.py
  15. 48 32
      backend/app/services/bambu_mqtt.py
  16. 56 26
      backend/app/services/print_scheduler.py
  17. 111 3
      backend/app/services/printer_manager.py
  18. 204 0
      backend/tests/integration/test_local_login_gate.py
  19. 26 0
      backend/tests/unit/services/test_bambu_mqtt.py
  20. 174 0
      backend/tests/unit/services/test_printer_manager.py
  21. 1 0
      backend/tests/unit/test_orphan_auth_cleanup_migration.py
  22. 178 0
      backend/tests/unit/test_scheduler_auto_drying.py
  23. 10 0
      frontend/scripts/check-i18n-parity.mjs
  24. 126 0
      frontend/src/__tests__/components/CameraTile.test.tsx
  25. 6 1
      frontend/src/__tests__/mocks/handlers.ts
  26. 2 0
      frontend/src/__tests__/pages/NotificationsPage.test.tsx
  27. 105 0
      frontend/src/__tests__/pages/PrintersPageDrying.test.ts
  28. 18 0
      frontend/src/api/client.ts
  29. 148 0
      frontend/src/components/CameraTile.tsx
  30. 216 0
      frontend/src/components/CameraWall.tsx
  31. 9 0
      frontend/src/components/OIDCProviderSettings.tsx
  32. 31 0
      frontend/src/i18n/locales/de.ts
  33. 31 0
      frontend/src/i18n/locales/en.ts
  34. 31 0
      frontend/src/i18n/locales/es.ts
  35. 31 0
      frontend/src/i18n/locales/fr.ts
  36. 31 0
      frontend/src/i18n/locales/it.ts
  37. 31 0
      frontend/src/i18n/locales/ja.ts
  38. 33 2
      frontend/src/i18n/locales/ko.ts
  39. 31 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 31 0
      frontend/src/i18n/locales/tr.ts
  41. 31 0
      frontend/src/i18n/locales/zh-CN.ts
  42. 31 0
      frontend/src/i18n/locales/zh-TW.ts
  43. 50 0
      frontend/src/pages/LoginPage.tsx
  44. 146 15
      frontend/src/pages/PrintersPage.tsx
  45. 41 2
      frontend/src/pages/SettingsPage.tsx

+ 11 - 0
.env.example

@@ -55,3 +55,14 @@ LOG_TO_FILE=true
 # In Docker, also bind-mount the host path into the container at the same
 # location (see docker-compose.yml for the matching volume snippet).
 # BAMBUDDY_EXTERNAL_ROOTS=
+
+# Local-login recovery bypass (#1589) — set to "true" / "1" / "yes" to
+# accept username + password credentials on /auth/login (and to allow the
+# /auth/forgot-password flow) even when the in-app setting "Disable local
+# login" is turned on. This is the documented "SSO is broken, let me back
+# in" path for an operator whose only normal sign-in route is via OIDC.
+# /auth/advanced-auth/status also reports local_login_enabled=true while
+# this is set, so the login page shows the credentials form to match.
+# LDAP is governed by its own ldap_enabled toggle and is not affected.
+# Leave unset for normal operation.
+# BAMBUDDY_LOCAL_LOGIN=true

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 3 - 1
README.md

@@ -142,6 +142,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
+- **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
 - **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
 - **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
@@ -160,9 +161,10 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **AMS Filament Backup status + control with pair view** — Mirrors BambuStudio's per-printer "AMS Filament Backup" auto-switch (when a spool runs out, the printer rolls over to a same-preset, same-colour spool in another slot). A small badge in the Filaments section header on each printer card shows the live state (blue circular-arrow icon = ON, dim = OFF, "?" = A1 family with no `cfg` field yet); click to open the AMS Filament Backup modal — a BambuStudio Auto Refill-style ring graphic per backup pair, with the filament colour as the ring fill and member slot labels (e.g. `A·1`, `B·3`) on contrast-aware pills around the band. Dual-extruder printers (H2D / H2C / X2D) carry an `R` / `L` badge per ring because the firmware can't cross extruders. State syncs in real time whether you toggled from Bambuddy, BambuStudio, or the printer's touchscreen. Bambuddy's "insufficient filament" check is **backup-aware**: when Backup is ON, the deficit check pools remaining grams across same-`(preset, colour)` spools on the printer, so the warning doesn't fire spuriously when the firmware will swap to a peer mid-print (#1762). Bambuddy's **Prefer Lowest Remaining Filament** sort also respects the toggle — when Backup is OFF the dispatcher skips the prefer-lowest sort entirely so it won't reach for a near-empty spool the printer can't roll off of.
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
-- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting
+- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting. Rotate-spool toggle is disabled per-AMS when any tray has filament threaded into the feed tube (the AMS mechanism is locked there — rotating would jam the filament)
 - **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
 - **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
+- **Continue drying while printing** — On capable hardware (H2D 01.03.00.00+, H2C / H2S / P2S / H2D Pro 01.02.00.00+, X2D / A2L 01.01.00.00+, X1C 01.11.02.00+), auto-drying can keep running during a print. Default off, opt-in toggle in Settings → Print Queue. Drying temperature is automatically capped 5°C below the idle preset (floor 40°C) to protect spools inside the hot enclosure
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
 - Dual external spool support for H2D (Ext-L / Ext-R)

+ 88 - 3
backend/app/api/routes/auth.py

@@ -112,6 +112,19 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
 )
 
 
+# #1589: read at call time, not import time, so tests can monkeypatch os.environ
+# between cases without re-importing the module.
+def _local_login_env_bypass() -> bool:
+    """Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy.
+
+    Bypasses the ``local_login_enabled`` DB setting on the local-credentials
+    code path AND the forgot-password endpoint so a server admin can recover
+    an install whose SSO provider is unreachable. Accepted truthy values:
+    ``true``, ``1``, ``yes`` (case-insensitive).
+    """
+    return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
+
+
 def _get_client_ip(request: Request) -> str:
     """Return the real client IP for rate-limiting purposes.
 
@@ -378,6 +391,13 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     client_ip = _get_client_ip(raw_request)
     await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
 
+    # Initialize `user` up front so every downstream branch can read/write
+    # it without UnboundLocalError. The LDAP success path sets it inside its
+    # own block; the local-credentials and email-credentials paths set it
+    # below. The original code relied on the local-credentials path running
+    # unconditionally to bind `user`; #1589 made that path skippable, so the
+    # init has to live here.
+    user = None
     # Check if LDAP is enabled
     ldap_user = None
     ldap_settings = await _get_ldap_settings(db)
@@ -415,12 +435,30 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
             logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
             ldap_user = None
 
+    # #1589: local username/password gate. LDAP keeps its own switch
+    # (ldap_enabled) and is not affected — a delegated directory has its
+    # own policy and lockouts and is closer to SSO than to local creds.
+    # The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server
+    # admin can recover an install whose SSO provider is unreachable
+    # without editing the DB.
+    from backend.app.models.settings import Settings as _Settings_for_local_login
+
+    local_login_allowed = ldap_user is not None or _local_login_env_bypass()
+    if not local_login_allowed:
+        setting_row = await db.execute(
+            select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+        )
+        row = setting_row.scalar_one_or_none()
+        # Default True when the row is absent — matches AppSettings default
+        # so fresh installs and tests behave like every release before #1589.
+        local_login_allowed = row is None or row.value.lower() == "true"
+
     # Try username-based authentication (skip if already authenticated via LDAP)
-    if not ldap_user:
+    if not ldap_user and local_login_allowed:
         user = await authenticate_user(db, request.username, request.password)
 
     # If username auth failed and advanced auth is enabled, try email-based authentication
-    if not user and not ldap_user:
+    if not user and not ldap_user and local_login_allowed:
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
@@ -428,6 +466,11 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     if not user:
         await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
         await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
+        # Same generic 401 either way — never tell the client whether the
+        # username exists or whether local login was disabled. The Settings
+        # UI and /auth/advanced-auth/status are the channels for that state;
+        # leaking it here would help credential-stuffing distinguish "local
+        # disabled" from "wrong password" across an install fleet.
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Incorrect username or password",
@@ -813,12 +856,39 @@ async def disable_advanced_auth(
 
 @router.get("/advanced-auth/status")
 async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
-    """Get advanced authentication status."""
+    """Get advanced authentication status.
+
+    Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
+    so the LoginPage can decide whether to render the credentials form and
+    whether to redirect unauthenticated visitors directly to an SSO
+    provider, in a single query. ``BAMBUDDY_LOCAL_LOGIN=true`` flips the
+    reported value back to True so the recovery path is visible.
+    """
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.models.settings import Settings as _Settings_for_local_login
+
     advanced_auth_enabled = await is_advanced_auth_enabled(db)
     smtp_configured = await get_smtp_settings(db) is not None
+
+    setting_row = await db.execute(
+        select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+    )
+    row = setting_row.scalar_one_or_none()
+    db_local_enabled = row is None or row.value.lower() == "true"
+    local_login_enabled = db_local_enabled or _local_login_env_bypass()
+
+    # Autologin provider must be both flagged AND enabled — disabling a
+    # provider should not silently keep redirecting visitors to it.
+    autologin = await db.execute(
+        select(OIDCProvider.id).where(OIDCProvider.is_autologin.is_(True), OIDCProvider.is_enabled.is_(True)).limit(1)
+    )
+    autologin_provider_id = autologin.scalar_one_or_none()
+
     return {
         "advanced_auth_enabled": advanced_auth_enabled,
         "smtp_configured": smtp_configured,
+        "local_login_enabled": local_login_enabled,
+        "autologin_provider_id": autologin_provider_id,
     }
 
 
@@ -884,6 +954,21 @@ async def forgot_password(
     secure link instead of a plaintext temporary password.  The new password is
     set only when the user clicks the link and POSTs to /forgot-password/confirm.
     """
+    # #1589: forgot-password is a local-credentials flow — useless when local
+    # login is disabled (the reset wouldn't grant access anyway). Same gate as
+    # /auth/login, with the same env-var bypass for SSO-broken recovery.
+    if not _local_login_env_bypass():
+        from backend.app.models.settings import Settings as _Settings_for_local_login
+
+        setting_row = await db.execute(
+            select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+        )
+        row = setting_row.scalar_one_or_none()
+        if row is not None and row.value.lower() != "true":
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Local login is disabled — use SSO instead.",
+            )
     # Check if advanced auth is enabled
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:

+ 17 - 1
backend/app/api/routes/mfa.py

@@ -34,7 +34,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from passlib.context import CryptContext
-from sqlalchemy import delete, select
+from sqlalchemy import delete, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 
@@ -1388,11 +1388,17 @@ async def create_oidc_provider(
         icon_content_type=icon_content_type,
         icon_etag=icon_etag,
         default_group_id=body.default_group_id,
+        is_autologin=body.is_autologin,
     )
     # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
     # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
     _enforce_auto_link_safety(provider)
     db.add(provider)
+    # #1589: at most one provider may be the autologin target. When a new one
+    # is created with the flag set, clear it on all others first so the
+    # session still satisfies the invariant after add.
+    if body.is_autologin:
+        await db.execute(update(OIDCProvider).where(OIDCProvider.is_autologin.is_(True)).values(is_autologin=False))
     await db.commit()
     await db.refresh(provider)
     return _build_provider_response(provider)
@@ -1471,6 +1477,16 @@ async def update_oidc_provider(
     # partial updates that each pass schema validation individually but are unsafe together.
     _enforce_auto_link_safety(provider)
 
+    # #1589: at most one provider may be the autologin target. Clear the flag
+    # on every other provider when this one becomes the autologin. Excludes
+    # the current row so SQLAlchemy doesn't fight our in-memory set above.
+    if body.is_autologin is True:
+        await db.execute(
+            update(OIDCProvider)
+            .where(OIDCProvider.id != provider.id, OIDCProvider.is_autologin.is_(True))
+            .values(is_autologin=False)
+        )
+
     await db.commit()
     await db.refresh(provider)
     return _build_provider_response(provider)

+ 38 - 1
backend/app/api/routes/printers.py

@@ -55,6 +55,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_heater,
     supports_chamber_temp,
     supports_drying,
+    supports_drying_while_printing,
 )
 from backend.app.utils.http import build_content_disposition
 
@@ -475,6 +476,11 @@ async def get_printer_status(
             except (ValueError, TypeError):
                 pass  # Skip K-profile entries with unparseable values
 
+    # Cached active-cycle drying params (filament + target temp) we sent
+    # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
+    # needs the cache to render "<filament> @ <temp>°C".
+    drying_targets = printer_manager.get_drying_targets(printer_id) or {}
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
         for ams_data in raw_data["ams"]:
@@ -536,9 +542,37 @@ async def get_printer_status(
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
 
+            ams_id_int = int(ams_data.get("id", 0))
+            target = drying_targets.get(ams_id_int) or {}
+            dry_target_temp: int | None = None
+            dry_filament: str | None = None
+            target_temp_val = target.get("temp")
+            target_fil_val = target.get("filament") or ""
+            if target_temp_val is not None:
+                try:
+                    dry_target_temp = int(target_temp_val)
+                except (TypeError, ValueError):
+                    dry_target_temp = None
+            if target_fil_val:
+                dry_filament = str(target_fil_val)
+            # Fallback: derive from first loaded tray when no cached target
+            # (drying started in a previous backend session, or cache wasn't
+            # seeded). Mirrors the popover seed heuristic.
+            if dry_target_temp is None or not dry_filament:
+                for tray in trays:
+                    if tray.tray_type:
+                        if not dry_filament:
+                            dry_filament = str(tray.tray_type)
+                        if dry_target_temp is None and tray.drying_temp:
+                            try:
+                                dry_target_temp = int(tray.drying_temp)
+                            except (TypeError, ValueError):
+                                pass
+                        break
+
             ams_units.append(
                 AMSUnit(
-                    id=ams_data.get("id", 0),
+                    id=ams_id_int,
                     humidity=humidity_value,
                     temp=ams_data.get("temp"),
                     is_ams_ht=is_ams_ht,
@@ -549,6 +583,8 @@ async def get_printer_status(
                     sw_ver=str(ams_data.get("sw_ver") or ""),
                     # Drying: dry_time > 0 means drying is active (minutes remaining)
                     dry_time=int(ams_data.get("dry_time") or 0),
+                    dry_target_temp=dry_target_temp,
+                    dry_filament=dry_filament,
                     module_type=str(ams_data.get("module_type") or ""),
                 )
             )
@@ -727,6 +763,7 @@ async def get_printer_status(
         ams_filament_backup=state.ams_filament_backup if state else None,
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
+        supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
         supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,

+ 33 - 3
backend/app/api/routes/settings.py

@@ -5,10 +5,10 @@ import zipfile
 from datetime import datetime
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, JSONResponse
 from pydantic import BaseModel, Field
-from sqlalchemy import delete, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
@@ -128,6 +128,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "queue_drying_enabled",
             "queue_drying_block",
             "ambient_drying_enabled",
+            "print_drying_enabled",
             "require_plate_clear",
             "queue_shortest_first",
             "default_bed_levelling",
@@ -138,6 +139,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_auto_provision",
+            "local_login_enabled",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -203,11 +205,39 @@ async def get_settings(
 async def update_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
 
+    # Safety refusals on disabling local login (#1589). Two failure modes
+    # would otherwise lock everyone out of the install:
+    #   1. No enabled OIDC provider exists — nobody could authenticate.
+    #   2. The caller has no UserOIDCLink — they would lock themselves out
+    #      even if other admins are linked.
+    # Either case returns HTTP 400 instead of silently saving. The
+    # ``BAMBUDDY_LOCAL_LOGIN=true`` env-var bypass on /auth/login is a
+    # separate recovery path; the refusals here protect the *default*
+    # configuration where the env var is absent.
+    if update_data.get("local_login_enabled") is False:
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+
+        enabled_count = await db.scalar(select(func.count(OIDCProvider.id)).where(OIDCProvider.is_enabled.is_(True)))
+        if not enabled_count:
+            raise HTTPException(
+                status_code=400,
+                detail="Cannot disable local login: no OIDC provider is enabled.",
+            )
+        if current_user is not None:
+            caller_links = await db.scalar(
+                select(func.count(UserOIDCLink.id)).where(UserOIDCLink.user_id == current_user.id)
+            )
+            if not caller_links:
+                raise HTTPException(
+                    status_code=400,
+                    detail="Cannot disable local login: your account has no OIDC link, so you would lock yourself out.",
+                )
+
     # Check if any MQTT settings are being updated
     mqtt_keys = {
         "mqtt_enabled",

+ 12 - 2
backend/app/api/routes/websocket.py

@@ -97,7 +97,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                 {
                     "type": "printer_status",
                     "printer_id": printer_id,
-                    "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                    "data": printer_state_to_dict(
+                        state,
+                        printer_id,
+                        printer_manager.get_model(printer_id),
+                        printer_manager.get_drying_targets(printer_id),
+                    ),
                 }
             )
 
@@ -129,7 +134,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                             {
                                 "type": "printer_status",
                                 "printer_id": printer_id,
-                                "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                                "data": printer_state_to_dict(
+                                    state,
+                                    printer_id,
+                                    printer_manager.get_model(printer_id),
+                                    printer_manager.get_drying_targets(printer_id),
+                                ),
                             }
                         )
 

+ 7 - 0
backend/app/core/database.py

@@ -3222,6 +3222,13 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
 
+    # Migration: Add is_autologin column to oidc_providers (#1589). Postgres
+    # rejects ``DEFAULT 0`` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)

+ 12 - 2
backend/app/main.py

@@ -1358,7 +1358,12 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
     await ws_manager.send_printer_status(
         printer_id,
-        printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+        printer_state_to_dict(
+            state,
+            printer_id,
+            printer_manager.get_model(printer_id),
+            printer_manager.get_drying_targets(printer_id),
+        ),
     )
 
 
@@ -1393,7 +1398,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
             logger.info("[Printer %s] Broadcasting AMS change via WebSocket", printer_id)
             await ws_manager.send_printer_status(
                 printer_id,
-                printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                printer_state_to_dict(
+                    state,
+                    printer_id,
+                    printer_manager.get_model(printer_id),
+                    printer_manager.get_drying_targets(printer_id),
+                ),
             )
     except Exception as e:
         logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)

+ 7 - 0
backend/app/models/oidc_provider.py

@@ -121,6 +121,13 @@ class OIDCProvider(Base):
     # SHA-256 hex of icon_data, served as the ETag header so clients can
     # revalidate via If-None-Match and receive 304 Not Modified.
     icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+    # When True, the LoginPage redirects unauthenticated visitors straight to
+    # this provider's authorize URL on mount (#1589). At most one provider can
+    # carry this flag at a time; setting it on a new provider clears it on the
+    # previous one. The frontend always falls back to the local form if the
+    # authorize-URL fetch fails or times out, and ``/login?fallback=local``
+    # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
+    is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
     @property
     def has_icon(self) -> bool:

+ 3 - 0
backend/app/schemas/auth.py

@@ -413,6 +413,7 @@ class OIDCProviderCreate(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589 — at most one provider may carry this
 
     @field_validator("issuer_url")
     @classmethod
@@ -469,6 +470,7 @@ class OIDCProviderUpdate(BaseModel):
     require_email_verified: bool | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool | None = None  # #1589
 
     @field_validator("scopes")
     @classmethod
@@ -515,6 +517,7 @@ class OIDCProviderResponse(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589
     # Set explicitly in the route handler from `icon_content_type is not None`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).

+ 5 - 0
backend/app/schemas/printer.py

@@ -186,6 +186,8 @@ class AMSUnit(BaseModel):
     dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
     dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
+    dry_target_temp: int | None = None  # Active-cycle target °C (Bambu doesn't echo this)
+    dry_filament: str | None = None  # Active-cycle filament name we sent
     module_type: str = ""  # "ams", "n3f", "n3s"
 
 
@@ -333,6 +335,9 @@ class PrinterStatus(BaseModel):
     awaiting_plate_clear: bool = False
     # AMS drying support
     supports_drying: bool = False
+    # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
+    # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
+    supports_drying_while_printing: bool = False
     # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
     supports_chamber_heater: bool = False
     # Linked archive for the active print (resolved via subtask_id). Frontend uses

+ 27 - 0
backend/app/schemas/settings.py

@@ -92,6 +92,16 @@ class AppSettings(BaseModel):
         default=False,
         description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
     )
+    print_drying_enabled: bool = Field(
+        default=False,
+        description=(
+            "Allow auto-drying to also fire on a printer that is currently printing, "
+            "when its model+firmware supports concurrent drying (H2D 01.03.00.00+, "
+            "H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L 01.01.00.00+, X1C 01.11.02.00+). "
+            "Drying temperature is automatically capped 5 degC below the idle preset "
+            "(floor 40 degC) to protect spools during print."
+        ),
+    )
     drying_presets: str = Field(
         default="",
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
@@ -329,6 +339,21 @@ class AppSettings(BaseModel):
         description="JSON array of 3 fan-speed preset values in % (0-100). Empty = use defaults [50, 75, 100]",
     )
 
+    # Local login (#1589) — when False, /auth/login rejects username+password
+    # credentials with HTTP 403 and the login page hides the credentials form,
+    # leaving only the OIDC SSO provider buttons. LDAP is governed by its own
+    # `ldap_enabled` toggle and is not affected. The env-var
+    # ``BAMBUDDY_LOCAL_LOGIN=true`` bypasses this gate at the route level so a
+    # server admin can recover an install whose SSO provider is unreachable
+    # without editing the DB.
+    local_login_enabled: bool = Field(
+        default=True,
+        description=(
+            "Allow username + password login on /auth/login. Disable when only SSO should be usable. "
+            "BAMBUDDY_LOCAL_LOGIN=true on the server overrides this to keep a recovery path open."
+        ),
+    )
+
     # LDAP authentication (#794)
     ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
     ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
@@ -413,6 +438,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    local_login_enabled: bool | None = None
     language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
@@ -425,6 +451,7 @@ class AppSettingsUpdate(BaseModel):
     queue_drying_enabled: bool | None = None
     queue_drying_block: bool | None = None
     ambient_drying_enabled: bool | None = None
+    print_drying_enabled: bool | None = None
     drying_presets: str | None = None
     ams_humidity_thresholds: str | None = None
     per_printer_mapping_expanded: bool | None = None

+ 48 - 32
backend/app/services/bambu_mqtt.py

@@ -496,6 +496,11 @@ class BambuMQTTClient:
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
+        # Per-AMS active-cycle target params (filament + temp) we sent on the
+        # last start. Bambu does not echo these back in the per-tick AMS push
+        # — only the dry_time countdown — so we cache what we sent to drive
+        # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
+        self._drying_targets: dict[int, dict[str, object]] = {}
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -2056,38 +2061,40 @@ class BambuMQTTClient:
 
         # Detect AMS drying-complete falling edge per-unit (#1349). When an
         # AMS's `dry_time` transitions from >0 to 0 the cycle just finished
-        # — fire the callback so smart-plug auto-off-after-drying can run.
-        # Works identically for queue-triggered, ambient, and manual drying
-        # because we observe the firmware-reported state, not our own intent.
-        if self.on_drying_complete:
-            for ams_unit in merged_ams:
-                try:
-                    ams_id = int(ams_unit.get("id", -1))
-                except (TypeError, ValueError):
-                    continue
-                if ams_id < 0:
-                    continue
-                # Only evaluate the edge when this update carries an explicit
-                # dry_time. An absent / unparseable value is NOT zero — treating
-                # it as 0 lets a tray-only partial fake a drying-complete edge
-                # (#1462). Skip without touching the remembered value so the
-                # next update that DOES carry dry_time sees the true previous.
-                raw_dry_time = ams_unit.get("dry_time")
-                if raw_dry_time is None:
-                    continue
-                try:
-                    current = int(raw_dry_time)
-                except (TypeError, ValueError):
-                    continue
-                previous = self._previous_dry_times.get(ams_id, 0)
-                self._previous_dry_times[ams_id] = current
-                if previous > 0 and current == 0:
-                    logger.info(
-                        "[%s] AMS %d drying complete (dry_time %d → 0)",
-                        self.serial_number,
-                        ams_id,
-                        previous,
-                    )
+        # — fire the callback so smart-plug auto-off-after-drying can run,
+        # and drop our cached target-cycle params so the badge stops claiming
+        # an active cycle. Works identically for queue-triggered, ambient,
+        # and manual drying because we observe the firmware-reported state.
+        for ams_unit in merged_ams:
+            try:
+                ams_id = int(ams_unit.get("id", -1))
+            except (TypeError, ValueError):
+                continue
+            if ams_id < 0:
+                continue
+            # Only evaluate the edge when this update carries an explicit
+            # dry_time. An absent / unparseable value is NOT zero — treating
+            # it as 0 lets a tray-only partial fake a drying-complete edge
+            # (#1462). Skip without touching the remembered value so the
+            # next update that DOES carry dry_time sees the true previous.
+            raw_dry_time = ams_unit.get("dry_time")
+            if raw_dry_time is None:
+                continue
+            try:
+                current = int(raw_dry_time)
+            except (TypeError, ValueError):
+                continue
+            previous = self._previous_dry_times.get(ams_id, 0)
+            self._previous_dry_times[ams_id] = current
+            if previous > 0 and current == 0:
+                logger.info(
+                    "[%s] AMS %d drying complete (dry_time %d → 0)",
+                    self.serial_number,
+                    ams_id,
+                    previous,
+                )
+                self._drying_targets.pop(ams_id, None)
+                if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
 
         # Create a hash of relevant AMS data to detect changes
@@ -4092,6 +4099,15 @@ class BambuMQTTClient:
             self.serial_number,
             wire_json,
         )
+        # Track the active-cycle target so the badge can show "PETG @ 65°C"
+        # while drying. Bambu only echoes dry_time on subsequent pushes.
+        if mode == 1:
+            self._drying_targets[ams_id] = {
+                "filament": filament or "",
+                "temp": int(temp),
+            }
+        else:
+            self._drying_targets.pop(ams_id, None)
         return True
 
     def _handle_kprofile_response(self, data: dict):

+ 56 - 26
backend/app/services/print_scheduler.py

@@ -31,7 +31,11 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
-from backend.app.services.printer_manager import printer_manager, supports_drying
+from backend.app.services.printer_manager import (
+    printer_manager,
+    supports_drying,
+    supports_drying_while_printing,
+)
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.printer_models import normalize_printer_model
@@ -1591,12 +1595,17 @@ class PrintScheduler:
     ):
         """Start drying on idle printers based on humidity.
 
-        Two modes (can both be enabled):
+        Three modes (can all be enabled independently):
         - queue_drying_enabled: Dry between scheduled queue prints
         - ambient_drying_enabled: Dry any idle printer when humidity is high, regardless of queue
+        - print_drying_enabled: Also evaluate printers that are currently printing,
+          when model+firmware supports "Print While Drying" (gated by
+          supports_drying_while_printing). Drying temperature is capped at
+          max(40, preset_temp - 5) to protect spools mid-print.
         """
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
         ambient_drying_enabled = await self._get_bool_setting(db, "ambient_drying_enabled")
+        print_drying_enabled = await self._get_bool_setting(db, "print_drying_enabled")
         if not queue_drying_enabled and not ambient_drying_enabled:
             # Stop active drying on all printers if both features disabled
             if self._drying_in_progress:
@@ -1618,7 +1627,9 @@ class PrintScheduler:
                     printers_with_scheduled.add(item.printer_id)
 
         # If only queue mode is on and no printers have scheduled items, stop drying
-        if not ambient_drying_enabled and not printers_with_scheduled:
+        # (but skip this short-circuit when print_drying_enabled is on — busy printers
+        # may still be eligible for mid-print drying regardless of queue state).
+        if not ambient_drying_enabled and not printers_with_scheduled and not print_drying_enabled:
             for pid in list(self._drying_in_progress):
                 logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
                 await self._stop_drying(pid)
@@ -1643,36 +1654,47 @@ class PrintScheduler:
         all_printers = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
         for printer in all_printers.scalars():
             pid = printer.id
-            if pid in busy_printers:
-                logger.debug("Auto-drying: printer %d skipped — busy", pid)
-                continue
-            # In queue-only mode, only dry printers that have scheduled prints
-            if not ambient_drying_enabled and pid not in printers_with_scheduled:
-                if self._drying_in_progress.get(pid):
-                    logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
-                    await self._stop_drying(pid)
-                logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+
+            # Resolve model+firmware up front — needed to decide whether this printer
+            # qualifies for mid-print drying (busy printer on capable hardware).
+            state = printer_manager.get_status(pid)
+            if not state:
+                logger.debug("Auto-drying: printer %d skipped — no state", pid)
                 continue
-            # When block mode is on, don't START new drying on printers with pending items.
-            # But allow already-drying printers through so humidity auto-stop logic still runs.
-            if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
-                logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+            model = printer_manager.get_model(pid)
+            firmware = state.firmware_version
+
+            mid_print = (
+                pid in busy_printers and print_drying_enabled and supports_drying_while_printing(model, firmware)
+            )
+
+            if pid in busy_printers and not mid_print:
+                logger.debug("Auto-drying: printer %d skipped — busy", pid)
                 continue
+
+            if not mid_print:
+                # In queue-only mode, only dry printers that have scheduled prints
+                if not ambient_drying_enabled and pid not in printers_with_scheduled:
+                    if self._drying_in_progress.get(pid):
+                        logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
+                        await self._stop_drying(pid)
+                    logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+                    continue
+                # When block mode is on, don't START new drying on printers with pending items.
+                # But allow already-drying printers through so humidity auto-stop logic still runs.
+                if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
+                    logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+                    continue
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
                 continue
-            if not self._is_printer_idle(pid, require_plate_clear):
+            if not mid_print and not self._is_printer_idle(pid, require_plate_clear):
                 logger.debug("Auto-drying: printer %d skipped — not idle", pid)
                 continue
 
-            # Check if this printer supports drying
-            state = printer_manager.get_status(pid)
-            if not state:
-                logger.debug("Auto-drying: printer %d skipped — no state", pid)
-                continue
-            model = printer_manager.get_model(pid)
-            firmware = state.firmware_version
-            if not supports_drying(model, firmware):
+            # Check drying capability. For mid-print path, supports_drying_while_printing
+            # was already verified when computing mid_print above.
+            if not mid_print and not supports_drying(model, firmware):
                 logger.debug("Auto-drying: printer %d skipped — model %s does not support drying", pid, model)
                 continue
 
@@ -1773,10 +1795,17 @@ class PrintScheduler:
 
                 temp, duration_hours, filament_type = params
 
+                # Mid-print drying: cap drying temperature to protect spools (Bambu warns
+                # "drying temperature must not exceed the filament's softening temperature"
+                # for Print While Drying). Floor at 40 degC — below that the dryer is
+                # ineffective and firmware will reject anyway.
+                if mid_print:
+                    temp = max(40, temp - 5)
+
                 # Start drying
                 logger.info(
                     "Auto-drying: printer %d AMS %d — humidity %d%% > threshold %d%%, "
-                    "starting %s drying at %d°C for %dh",
+                    "starting %s drying at %d°C for %dh%s",
                     pid,
                     ams_id,
                     humidity,
@@ -1784,6 +1813,7 @@ class PrintScheduler:
                     filament_type,
                     temp,
                     duration_hours,
+                    " (mid-print)" if mid_print else "",
                 )
                 success = printer_manager.send_drying_command(
                     pid, ams_id, temp, duration_hours, mode=1, filament=filament_type

+ 111 - 3
backend/app/services/printer_manager.py

@@ -192,6 +192,51 @@ def supports_drying(model: str | None, firmware: str | None) -> bool:
     return True
 
 
+# Minimum firmware versions for AMS "Print While Drying" — drying that runs CONCURRENTLY
+# with an active print. Strictly stricter than _DRYING_MIN_FIRMWARE (idle drying). Verified
+# against Bambu wiki release notes — the canonical phrasing on every supported model is
+# "printing while filament is drying" / "Print While Drying". Models absent from the wiki
+# release notes (A1, A1 Mini, P1*, X1 non-C, X1E) are intentionally excluded — the firmware
+# will reject the command in those cases anyway via dry_sf_reason=[0] (TaskOccupied).
+_DRY_WHILE_PRINTING_MIN_FIRMWARE: dict[str, str] = {
+    "H2D": "01.03.00.00",
+    "H2D PRO": "01.02.00.00",
+    "H2DPRO": "01.02.00.00",
+    "O1E": "01.02.00.00",  # H2D Pro SSDP code
+    "O2D": "01.02.00.00",  # H2D Pro alternate code
+    "H2C": "01.02.00.00",
+    "O1C": "01.02.00.00",  # H2C SSDP code
+    "O1C2": "01.02.00.00",  # H2C dual-nozzle SSDP code
+    "H2S": "01.02.00.00",
+    "X2D": "01.01.00.00",
+    "N6": "01.01.00.00",  # X2D internal code
+    "X1C": "01.11.02.00",
+    "BL-P001": "01.11.02.00",  # X1C internal code
+    "P2S": "01.02.00.00",
+    "N7": "01.02.00.00",  # P2S internal code
+    "A2L": "01.01.00.00",
+    "N9": "01.01.00.00",  # A2L internal code
+}
+
+
+def supports_drying_while_printing(model: str | None, firmware: str | None) -> bool:
+    """Check if a printer model+firmware supports running AMS drying CONCURRENTLY
+    with an active print.
+
+    Distinct from supports_drying() — that gates idle drying. This gate is strict:
+    only models explicitly confirmed by Bambu wiki release notes are allowed.
+    On unsupported models the firmware returns dry_sf_reason=[0] (TaskOccupied)
+    while a print is running, so being conservative here costs nothing — the
+    firmware is the ultimate arbiter, this gate just hides UI affordances.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    if model_upper not in _DRY_WHILE_PRINTING_MIN_FIRMWARE:
+        return False
+    return bool(firmware and firmware >= _DRY_WHILE_PRINTING_MIN_FIRMWARE[model_upper])
+
+
 class PrinterInfo:
     """Basic printer info for callbacks."""
 
@@ -302,7 +347,12 @@ class PrinterManager:
 
             await ws_manager.send_printer_status(
                 printer_id,
-                printer_state_to_dict(state, printer_id, self.get_model(printer_id)),
+                printer_state_to_dict(
+                    state,
+                    printer_id,
+                    self.get_model(printer_id),
+                    self.get_drying_targets(printer_id),
+                ),
             )
         except Exception as e:
             logger.warning(
@@ -511,6 +561,18 @@ class PrinterManager:
         """Get the cached model for a printer."""
         return self._models.get(printer_id)
 
+    def get_drying_targets(self, printer_id: int) -> dict[int, dict] | None:
+        """Get cached active drying target params keyed by AMS id.
+
+        Returned dict shape: ``{ams_id: {"filament": str, "temp": int}}``.
+        Returns ``None`` when the printer is not connected. The cache is
+        seeded by ``send_drying_command(mode=1)`` and cleared when drying
+        stops or on the ``dry_time`` falling edge (handled inside
+        ``BambuMQTTClient``).
+        """
+        client = self._clients.get(printer_id)
+        return client._drying_targets if client else None
+
     def get_all_statuses(self) -> dict[int, PrinterState]:
         """Get status of all connected printers (checks for stale connections)."""
         result = {}
@@ -851,13 +913,21 @@ def resolve_plate_id(state) -> int | None:
     return parse_plate_id(state.gcode_file)
 
 
-def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
+def printer_state_to_dict(
+    state: PrinterState,
+    printer_id: int | None = None,
+    model: str | None = None,
+    drying_targets: dict[int, dict] | None = None,
+) -> dict:
     """Convert PrinterState to a JSON-serializable dict.
 
     Args:
         state: The printer state to convert
         printer_id: Optional printer ID for generating cover URLs
         model: Optional printer model for filtering unsupported features
+        drying_targets: Optional per-AMS active-cycle params
+            (``{ams_id: {"filament": str, "temp": int}}``) sourced from the
+            BambuMQTTClient cache so the badge can display "PETG @ 65°C".
     """
     # Parse AMS data from raw_data
     ams_units = []
@@ -943,9 +1013,43 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
 
+            # Active-cycle filament + target temperature for the badge.
+            # Bambu does not echo the cycle's chosen filament/temp on the
+            # per-tick AMS push, so prefer the cached target from the last
+            # ``send_drying_command``. When we have no record (drying
+            # started in a previous backend lifetime, or the cache was
+            # never seeded), fall back to the first loaded tray's
+            # tray_type + RFID-recommended drying_temp — the same heuristic
+            # the popover already uses to seed defaults.
+            ams_id_int = int(ams_data.get("id", 0))
+            target = (drying_targets or {}).get(ams_id_int)
+            dry_target_temp: int | None = None
+            dry_filament: str | None = None
+            if target:
+                temp_val = target.get("temp")
+                fil_val = target.get("filament") or ""
+                if temp_val is not None:
+                    try:
+                        dry_target_temp = int(temp_val)
+                    except (TypeError, ValueError):
+                        dry_target_temp = None
+                if fil_val:
+                    dry_filament = str(fil_val)
+            if dry_target_temp is None or not dry_filament:
+                for tray in trays:
+                    if tray.get("tray_type"):
+                        if not dry_filament:
+                            dry_filament = str(tray["tray_type"])
+                        if dry_target_temp is None and tray.get("drying_temp"):
+                            try:
+                                dry_target_temp = int(tray["drying_temp"])
+                            except (TypeError, ValueError):
+                                pass
+                        break
+
             ams_units.append(
                 {
-                    "id": int(ams_data.get("id", 0)),
+                    "id": ams_id_int,
                     "humidity": humidity_value,
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
@@ -961,6 +1065,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),
                     # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)
                     "dry_sf_reason": list(ams_data.get("dry_sf_reason") or []),
+                    # Active-cycle filament name + target temperature
+                    "dry_target_temp": dry_target_temp,
+                    "dry_filament": dry_filament,
                     # Module type: "ams", "n3f", "n3s" (from get_version)
                     "module_type": str(ams_data.get("module_type") or ""),
                 }
@@ -1084,6 +1191,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         ],
         # AMS drying support
         "supports_drying": supports_drying(model, state.firmware_version),
+        "supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
         # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
         # Pushed via WebSocket so the printer card picks up plate transitions within
         # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).

+ 204 - 0
backend/tests/integration/test_local_login_gate.py

@@ -0,0 +1,204 @@
+"""Integration tests for the local login gate + autologin (#1589).
+
+Covers the four contracts described on the GitHub issue:
+1. POST /auth/login rejects local credentials when local_login_enabled=false
+   AND the BAMBUDDY_LOCAL_LOGIN env var is not set.
+2. The BAMBUDDY_LOCAL_LOGIN=true env var bypasses the gate (recovery path).
+3. POST /auth/forgot-password is gated by the same flag (with the same bypass).
+4. GET /auth/advanced-auth/status surfaces both new fields so the LoginPage
+   can render the right UI in a single query.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.services.ldap_service import LDAPUserInfo
+
+
+async def _set_setting(db: AsyncSession, key: str, value: str) -> None:
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    row = result.scalar_one_or_none()
+    if row is None:
+        db.add(Settings(key=key, value=value))
+    else:
+        row.value = value
+    await db.commit()
+
+
+async def _enable_auth(async_client: AsyncClient, username: str = "gateadm") -> None:
+    """Set up an auth-enabled install with a known admin so /auth/login is reachable."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": "GatePass1!",
+        },
+    )
+
+
+class TestLocalLoginGate:
+    """The `local_login_enabled` setting blocks /auth/login + /auth/forgot-password
+    when the env-var recovery bypass is not in play."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_default_allows_local_credentials(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Default install (setting absent) keeps the pre-#1589 behaviour."""
+        await _enable_auth(async_client, "gatedefault")
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatedefault", "password": "GatePass1!"},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_rejected_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """With local_login_enabled=false and no env bypass, valid creds are
+        rejected with the same generic 401 as bad creds (no UI-stating leak)."""
+        await _enable_auth(async_client, "gatedeny")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatedeny", "password": "GatePass1!"},
+        )
+        assert response.status_code == 401
+        # Same wording as wrong-password 401 — never leaks whether local
+        # login is disabled (would help credential stuffing prioritise).
+        assert "Incorrect username or password" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_var_bypasses_local_disabled_gate(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """BAMBUDDY_LOCAL_LOGIN=true opens the recovery path even when the
+        DB setting forbids local login (SSO-broken admin recovery)."""
+        await _enable_auth(async_client, "gatebypass")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatebypass", "password": "GatePass1!"},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_rejected_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """Forgot-password is a local-credentials flow — useless when local
+        login is off (the reset wouldn't grant access anyway)."""
+        await _enable_auth(async_client, "gatefp")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        response = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "x@example.com"},
+        )
+        assert response.status_code == 403
+        assert "Local login is disabled" in response.json()["detail"]
+
+
+class TestLdapLoginNotAffectedByGate:
+    """LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
+    entirely. This is the regression suite for the refactor in #1589 — without
+    these tests, an LDAP user could fail to log in when local login is
+    disabled even though the gate is supposed to leave LDAP alone."""
+
+    async def _enable_ldap(self, db: AsyncSession) -> None:
+        for key, value in {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.test",
+            "ldap_bind_dn": "cn=svc,dc=test,dc=com",
+            "ldap_bind_password": "x",
+            "ldap_search_base": "dc=test,dc=com",
+            "ldap_user_filter": "(uid={username})",
+            "ldap_security": "ldaps",
+            "ldap_group_mapping": "{}",
+            "ldap_auto_provision": "true",
+            "ldap_default_group": "",
+        }.items():
+            await _set_setting(db, key, value)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ldap_login_succeeds_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """LDAP-authenticated login must still issue a JWT even when the
+        local-login gate is off and no env-var bypass is set. The original
+        cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
+        await _enable_auth(async_client, "ldapseed")
+        await self._enable_ldap(db_session)
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        fake_ldap = LDAPUserInfo(
+            username="ldapuser",
+            email="ldapuser@test.com",
+            display_name="LDAP User",
+            groups=[],
+        )
+        with patch(
+            "backend.app.services.ldap_service.authenticate_ldap_user",
+            return_value=fake_ldap,
+        ):
+            response = await async_client.post(
+                "/api/v1/auth/login",
+                json={"username": "ldapuser", "password": "anything"},
+            )
+
+        assert response.status_code == 200, response.text
+        assert "access_token" in response.json()
+        assert response.json()["user"]["username"] == "ldapuser"
+
+
+class TestAdvancedAuthStatusSurfacesGate:
+    """The /auth/advanced-auth/status endpoint feeds the LoginPage's render
+    decisions in a single query — it must surface both new #1589 fields."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_includes_local_login_and_autologin(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        result = response.json()
+        assert "local_login_enabled" in result
+        assert "autologin_provider_id" in result
+        # Default install: local on, no autologin provider.
+        assert result["local_login_enabled"] is True
+        assert result["autologin_provider_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_var_bypass_flips_status_back_to_true(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """When the DB setting is false but the env-var bypass is set, the
+        status reports local_login_enabled=true so the LoginPage shows the
+        credentials form (matching what the route will actually accept)."""
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
+
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        assert response.json()["local_login_enabled"] is True

+ 26 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3729,6 +3729,32 @@ class TestSendDryingCommand:
         qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
         assert qos == 1
 
+    def test_start_caches_target_for_badge(self, mqtt_client):
+        """mode=1 send populates _drying_targets so the badge can render it."""
+        mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
+        assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65}
+
+    def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
+        """A second start on the same AMS replaces the cached target."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
+        mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
+        assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70}
+
+    def test_stop_clears_target(self, mqtt_client):
+        """mode=0 send drops the cache so the badge stops showing the target."""
+        mqtt_client.send_drying_command(ams_id=1, temp=55, duration=4, mode=1, filament="PLA")
+        assert 1 in mqtt_client._drying_targets
+        mqtt_client.send_drying_command(ams_id=1, temp=0, duration=0, mode=0)
+        assert 1 not in mqtt_client._drying_targets
+
+    def test_targets_isolated_per_ams_id(self, mqtt_client):
+        """Stopping one AMS doesn't affect another AMS's cached target."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
+        mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
+        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
+        assert 0 not in mqtt_client._drying_targets
+        assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80}
+
 
 class TestStartPrintAmsMapping:
     """Tests for ams_mapping/ams_mapping2 construction in start_print().

+ 174 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -17,6 +17,7 @@ from backend.app.services.printer_manager import (
     printer_state_to_dict,
     supports_chamber_temp,
     supports_drying,
+    supports_drying_while_printing,
 )
 
 
@@ -1244,6 +1245,94 @@ class TestStatusKeyDryingDedup:
         assert result1["ams"] != result2["ams"]
 
 
+class TestDryingTargetExposure:
+    """Tests for dry_target_temp / dry_filament surfacing on AMS state dict.
+
+    Bambu does not echo the active cycle's chosen filament + target
+    temperature on the per-tick AMS push, only the dry_time countdown.
+    The badge needs the cached target so it can render "PETG @ 65°C".
+    """
+
+    def _state_with_ams(self, ams_data: dict) -> object:
+        state = MagicMock()
+        state.connected = True
+        state.state = "IDLE"
+        state.current_print = None
+        state.subtask_name = None
+        state.gcode_file = None
+        state.progress = 0
+        state.remaining_time = 0
+        state.layer_num = 0
+        state.total_layers = 0
+        state.temperatures = {"nozzle": 25, "bed": 25}
+        state.hms_errors = []
+        state.ams_status_main = 0
+        state.ams_status_sub = 0
+        state.tray_now = None
+        state.wifi_signal = -50
+        state.stg_cur = -1
+        state.raw_data = {"ams": [ams_data]}
+        return state
+
+    def test_cached_target_wins_over_tray_fallback(self):
+        """When the cache has a target for this AMS, use it verbatim — even
+        if the loaded tray's filament/recommended-drying-temp differ."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "drying_temp": 50, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={0: {"filament": "PETG", "temp": 65}})
+        assert result["ams"][0]["dry_filament"] == "PETG"
+        assert result["ams"][0]["dry_target_temp"] == 65
+
+    def test_falls_back_to_loaded_tray_when_no_cache(self):
+        """No cached target → derive from first loaded tray's tray_type +
+        RFID-recommended drying_temp (popover seed heuristic)."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [
+                    {"id": 0, "tray_type": "ABS", "drying_temp": 70, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets=None)
+        assert result["ams"][0]["dry_filament"] == "ABS"
+        assert result["ams"][0]["dry_target_temp"] == 70
+
+    def test_returns_none_when_no_cache_and_empty_trays(self):
+        """No cache + no loaded tray with tray_type → both fields are None."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [{"id": 0}],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={})
+        assert result["ams"][0]["dry_filament"] is None
+        assert result["ams"][0]["dry_target_temp"] is None
+
+    def test_targets_for_other_ams_id_dont_leak(self):
+        """A cached target for AMS 1 must not surface on AMS 0."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 600,
+                "tray": [{"id": 0}],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={1: {"filament": "PETG", "temp": 65}})
+        assert result["ams"][0]["dry_filament"] is None
+        assert result["ams"][0]["dry_target_temp"] is None
+
+
 class TestSupportsChamberTemp:
     """Tests for supports_chamber_temp helper function."""
 
@@ -1416,6 +1505,91 @@ class TestSupportsDrying:
         assert supports_drying("a1", "99.99.99.99") is False
 
 
+class TestSupportsDryingWhilePrinting:
+    """Tests for the supports_drying_while_printing gate (concurrent drying during print).
+
+    Stricter than supports_drying — only models explicitly confirmed by Bambu wiki
+    release notes are allowed (verified phrase: "printing while filament is drying"
+    / "Print While Drying").
+    """
+
+    def test_known_supported_with_firmware(self):
+        """Matrix-confirmed models with min firmware return True."""
+        assert supports_drying_while_printing("H2D", "01.03.00.00") is True
+        assert supports_drying_while_printing("H2D Pro", "01.02.00.00") is True
+        assert supports_drying_while_printing("O1E", "01.02.00.00") is True
+        assert supports_drying_while_printing("O2D", "01.02.00.00") is True
+        assert supports_drying_while_printing("H2C", "01.02.00.00") is True
+        assert supports_drying_while_printing("O1C", "01.02.00.00") is True
+        assert supports_drying_while_printing("O1C2", "01.02.00.00") is True
+        assert supports_drying_while_printing("H2S", "01.02.00.00") is True
+        assert supports_drying_while_printing("X2D", "01.01.00.00") is True
+        assert supports_drying_while_printing("N6", "01.01.00.00") is True
+        assert supports_drying_while_printing("X1C", "01.11.02.00") is True
+        assert supports_drying_while_printing("BL-P001", "01.11.02.00") is True
+        assert supports_drying_while_printing("P2S", "01.02.00.00") is True
+        assert supports_drying_while_printing("N7", "01.02.00.00") is True
+        assert supports_drying_while_printing("A2L", "01.01.00.00") is True
+        assert supports_drying_while_printing("N9", "01.01.00.00") is True
+
+    def test_known_supported_below_min_firmware(self):
+        """Matrix-confirmed models on too-old firmware return False."""
+        assert supports_drying_while_printing("H2D", "01.02.30.00") is False
+        assert supports_drying_while_printing("X1C", "01.11.01.00") is False
+        assert supports_drying_while_printing("P2S", "01.01.99.99") is False
+        assert supports_drying_while_printing("H2S", "01.01.99.99") is False
+        assert supports_drying_while_printing("A2L", "01.00.99.99") is False
+
+    def test_not_in_matrix_excluded(self):
+        """Models absent from the matrix return False regardless of firmware.
+
+        P1*, A1, A1 Mini, X1 (non-C), X1E are intentionally excluded — their wiki
+        release notes never mention "Print While Drying" / "printing while filament
+        is drying".
+        """
+        for model in [
+            "P1P",
+            "P1S",
+            "C11",
+            "C12",
+            "A1",
+            "A1 MINI",
+            "A1MINI",
+            "N1",
+            "N2S",
+            "X1",
+            "X1E",
+            "BL-P002",
+            "C13",
+        ]:
+            assert supports_drying_while_printing(model, "99.99.99.99") is False, f"Expected False for {model}"
+
+    def test_no_firmware_returns_false(self):
+        """Missing firmware version returns False even for supported models."""
+        assert supports_drying_while_printing("H2D", None) is False
+        assert supports_drying_while_printing("P2S", None) is False
+
+    def test_none_model_returns_false(self):
+        """None model returns False."""
+        assert supports_drying_while_printing(None, "01.03.00.00") is False
+
+    def test_case_insensitive(self):
+        """Model matching is case-insensitive."""
+        assert supports_drying_while_printing("h2d", "01.03.00.00") is True
+        assert supports_drying_while_printing("p2s", "01.02.00.00") is True
+        assert supports_drying_while_printing("a1", "99.99.99.99") is False
+
+    def test_unknown_model_returns_false(self):
+        """Unknown models default to FALSE (strict gate — not the lenient default-allow).
+
+        This contrasts with supports_drying which defaults to True for unknown
+        models. For while-printing the cost of being wrong is real (firmware
+        rejection mid-print is annoying; melted spool is worse), so we err
+        toward conservative.
+        """
+        assert supports_drying_while_printing("FUTURE_MODEL", "99.99.99.99") is False
+
+
 class TestGetDerivedStatusName:
     """Tests for get_derived_status_name function."""
 

+ 1 - 0
backend/tests/unit/test_orphan_auth_cleanup_migration.py

@@ -52,6 +52,7 @@ def _register_all_models():
     # not re-exported from __init__.py.
     from backend.app.models import (  # noqa: F401
         external_link,
+        print_log,
         print_queue,
         project_bom,
         slot_preset,

+ 178 - 0
backend/tests/unit/test_scheduler_auto_drying.py

@@ -1005,3 +1005,181 @@ class TestGetHumidityThresholds:
         db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
         result = await scheduler._get_humidity_thresholds(db)
         assert result == {"default": 60, "PLA": 50, "ASA": 30}
+
+
+class TestMidPrintDrying(_DryingTestBase):
+    """Tests for the print_drying_enabled path — drying that runs CONCURRENTLY
+    with an active print on capable hardware (H2D / H2C / H2S / P2S / X2D / X1C /
+    A2L / H2D Pro on recent firmware). Distinct from idle drying.
+
+    Verifies:
+      - With the toggle ON and capable hardware, a printer in the busy set is
+        still evaluated and drying fires at the capped temperature.
+      - The temperature cap is max(40, preset_temp - 5) — protects spools.
+      - With the toggle OFF, the existing busy-printer skip still applies.
+      - With the toggle ON but unsupported firmware, the busy-printer skip
+        still applies (gated by supports_drying_while_printing).
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _ams_unit(humidity: str = "75"):
+        return {
+            "id": 0,
+            "module_type": "n3f",
+            "dry_time": 0,
+            "humidity_raw": humidity,
+            "dry_sf_reason": [],
+            "tray": [{"tray_type": "PLA"}],
+        }
+
+    def _state(self, firmware: str):
+        state = MagicMock()
+        state.raw_data = {"ams": [self._ams_unit()]}
+        state.firmware_version = firmware
+        return state
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
+        """Toggle ON + capable hardware: running printer dries at capped temp."""
+        mock_pm.get_status.return_value = self._state("01.03.00.00")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        # Printer 1 is in busy_printers — would normally be skipped
+        await scheduler._check_auto_drying(db, [], {1})
+
+        # PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
+        assert 1 in scheduler._drying_in_progress
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_temp_cap_applied_above_floor(self, mock_pm, scheduler):
+        """Higher-temp filament (PETG n3f=65) caps to 60, not floor."""
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PETG"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.03.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        # PETG preset 65 -> max(40, 65-5) = 60
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 60, 12, mode=1, filament="PETG")
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_running_printer_skipped_when_toggle_off(self, mock_sd, mock_pm, scheduler):
+        """Toggle OFF: running printer is skipped even on capable hardware."""
+        mock_pm.get_status.return_value = self._state("01.03.00.00")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("false"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        mock_pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_printer_skipped_when_firmware_too_old(self, mock_pm, scheduler):
+        """Toggle ON but firmware below matrix threshold: skip."""
+        # H2D matrix minimum is 01.03.00.00; this is below
+        mock_pm.get_status.return_value = self._state("01.02.30.00")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        mock_pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_printer_skipped_when_model_excluded(self, mock_pm, scheduler):
+        """Toggle ON, recent firmware, but excluded model (A1): skip."""
+        mock_pm.get_status.return_value = self._state("99.99.99.99")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "A1"
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        mock_pm.send_drying_command.assert_not_called()

+ 10 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -168,6 +168,7 @@ const DE_COGNATES = [
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'China', 'Proxy', 'Start',
   'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // French cognates — many UI labels overlap with English exactly.
@@ -207,6 +208,7 @@ const FR_COGNATES = [
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Navigation', 'Budget', 'Commit', 'Designer',
   'ntfy, Pushover, Discord, etc.',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Italian cognates.
@@ -235,6 +237,7 @@ const IT_COGNATES = [
   'Hex: #{{hex}}',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Designer',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Japanese: very few cognates because of script difference. Almost
@@ -248,6 +251,7 @@ const JA_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Portuguese (BR) cognates.
@@ -276,6 +280,7 @@ const PT_BR_COGNATES = [
   'Expand dispatch details', 'Collapse dispatch details',
   'e.g., Home Assistant, OctoPrint', 'ntfy, Pushover, Discord, etc.',
   'Proxy', 'total: {{minutes}} min',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Chinese (Simplified): very few cognates beyond brand names.
@@ -287,6 +292,7 @@ const ZH_CN_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 const ZH_TW_COGNATES = [
@@ -297,6 +303,7 @@ const ZH_TW_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Korean: script difference means almost nothing is identical.
@@ -316,6 +323,7 @@ const KO_COGNATES = [
   '{{printer}}: {{error}}',                           // pure placeholders
   '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}', // pure placeholders
   'Obico ML API URL',                                 // product name (Obico)
+  '{{filament}} @ {{temp}}°C',                        // drying badge format
 ];
 
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
@@ -334,6 +342,7 @@ const ES_COGNATES = [
   'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim
@@ -349,6 +358,7 @@ const TR_COGNATES = [
   '{{count}} filament', '{{printer}}: {{error}}', '{{weight}}g',
   'Filament {{index}} ({{type}})',
   'EC984C,#6CD4BC,A66EB9,D87694',
+  '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 
 const IDENTICAL_TO_EN_ALLOWED = {

+ 126 - 0
frontend/src/__tests__/components/CameraTile.test.tsx

@@ -0,0 +1,126 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, screen } from '@testing-library/react';
+import { render } from '../utils';
+import { CameraTile } from '../../components/CameraTile';
+
+// The shared render() util mounts AuthProvider, which fires an async
+// /auth/me probe on mount. Each test absorbs that settle with a single
+// `await act(async () => {})` after render so the AuthProvider state
+// update doesn't bleed into the assertion phase as an act() warning.
+async function flushMicrotasks() {
+  await act(async () => {
+    await Promise.resolve();
+  });
+}
+
+describe('CameraTile', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.restoreAllMocks();
+  });
+
+  it('renders the live stream URL in live mode', async () => {
+    render(
+      <CameraTile
+        printerId={42}
+        printerName="X1C-Lab"
+        mode="live"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+    await flushMicrotasks();
+    const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+    expect(img.src).toContain('/api/v1/printers/42/camera/stream');
+    expect(img.src).toContain('fps=8');
+  });
+
+  it('renders the snapshot URL and refreshes on the interval', async () => {
+    render(
+      <CameraTile
+        printerId={7}
+        printerName="P1S-Garage"
+        mode="snapshot"
+        snapshotIntervalMs={1000}
+        connected
+      />,
+    );
+    await flushMicrotasks();
+    const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
+    expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
+
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
+    expect(refreshed).toContain('/api/v1/printers/7/camera/snapshot');
+    expect(refreshed).not.toBe(initial);
+  });
+
+  it('shows an offline placeholder when not connected', async () => {
+    render(
+      <CameraTile
+        printerId={1}
+        printerName="A1-Offline"
+        mode="live"
+        snapshotIntervalMs={5000}
+        connected={false}
+      />,
+    );
+    await flushMicrotasks();
+    expect(screen.queryByAltText('A1-Offline')).toBeNull();
+  });
+
+  it('shows the paused placeholder in paused mode', async () => {
+    render(
+      <CameraTile
+        printerId={9}
+        printerName="H2D-Booth"
+        mode="paused"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+    await flushMicrotasks();
+    expect(screen.queryByAltText('H2D-Booth')).toBeNull();
+  });
+
+  it('POSTs /camera/stop when leaving live mode', async () => {
+    const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(
+      new Response(null, { status: 200 }),
+    );
+    const { rerender } = render(
+      <CameraTile
+        printerId={11}
+        printerName="X1C-Stop"
+        mode="live"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+    await flushMicrotasks();
+    fetchMock.mockClear();
+
+    await act(async () => {
+      rerender(
+        <CameraTile
+          printerId={11}
+          printerName="X1C-Stop"
+          mode="snapshot"
+          snapshotIntervalMs={5000}
+          connected
+        />,
+      );
+    });
+
+    const stopCalls = fetchMock.mock.calls.filter(([url]) =>
+      String(url).includes('/api/v1/printers/11/camera/stop'),
+    );
+    expect(stopCalls.length).toBeGreaterThan(0);
+  });
+});

+ 6 - 1
frontend/src/__tests__/mocks/handlers.ts

@@ -493,7 +493,12 @@ export const handlers = [
     HttpResponse.json({ totp_enabled: false, email_otp_enabled: false, backup_codes_remaining: 0 })
   ),
   http.get('/api/v1/auth/advanced-auth/status', () =>
-    HttpResponse.json({ advanced_auth_enabled: false, smtp_configured: false })
+    HttpResponse.json({
+      advanced_auth_enabled: false,
+      smtp_configured: false,
+      local_login_enabled: true,
+      autologin_provider_id: null,
+    })
   ),
   http.get('/api/v1/auth/ldap/status', () =>
     HttpResponse.json({ ldap_enabled: false, ldap_configured: false })

+ 2 - 0
frontend/src/__tests__/pages/NotificationsPage.test.tsx

@@ -20,6 +20,8 @@ const mockPreferences = {
 const mockAdvancedAuthEnabled = {
   advanced_auth_enabled: true,
   smtp_configured: true,
+  local_login_enabled: true,
+  autologin_provider_id: null,
 };
 
 const mockSettingsWithNotifications = {

+ 105 - 0
frontend/src/__tests__/pages/PrintersPageDrying.test.ts

@@ -219,3 +219,108 @@ describe('rotate tray option', () => {
     expect(rotateTray).toBe(false);
   });
 });
+
+describe('rotate tray gate (per-AMS tray.state === 11)', () => {
+  /**
+   * Mirrors the gate from PrintersPage.tsx — rotation is physically impossible
+   * when ANY tray in the targeted AMS has its filament threaded out into the
+   * feed tube. The whole AMS rotates as one mechanism (all 4 spools turn
+   * together), so a single loaded slot locks the entire unit.
+   *
+   * Per-tray Bambu `state`:
+   *   9  = empty (no spool)
+   *   10 = spool present, NOT loaded into tube (rotation possible)
+   *   11 = loaded into tube (rotation impossible)
+   *
+   * This catches both mid-print (active feed) AND idle-with-threaded-filament
+   * — the H2D's post-print state leaves filament in the tube but tray_now
+   * resets to 255, which a tray_now-only check would silently miss.
+   */
+  type TrayLike = { state?: number };
+  type AmsLike = { id: number; tray?: TrayLike[] };
+
+  function isTrayLoadedInThisAms(
+    amsData: AmsLike[],
+    targetAmsId: number | null,
+  ): boolean {
+    if (targetAmsId === null) return false;
+    const targetAms = amsData.find(a => a.id === targetAmsId);
+    return (targetAms?.tray ?? []).some(tray => tray.state === 11);
+  }
+
+  it('returns false when AMS id is null (modal closed)', () => {
+    const ams = [{ id: 0, tray: [{ state: 11 }] }];
+    expect(isTrayLoadedInThisAms(ams, null)).toBe(false);
+  });
+
+  it('returns false when targeted AMS not found in amsData', () => {
+    const ams = [{ id: 0, tray: [{ state: 11 }] }];
+    expect(isTrayLoadedInThisAms(ams, 1)).toBe(false);
+  });
+
+  it('returns false when all trays are empty (state=9)', () => {
+    const ams = [{ id: 0, tray: [{ state: 9 }, { state: 9 }, { state: 9 }, { state: 9 }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('returns false when all trays have spools but none loaded (state=10)', () => {
+    // The "all AMS have spools loaded" case the gate now catches correctly:
+    // spool present in the slot, NOT threaded into the tube → rotation possible.
+    const ams = [{ id: 0, tray: [{ state: 10 }, { state: 10 }, { state: 10 }, { state: 10 }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('returns true when ANY tray is loaded into tube (state=11)', () => {
+    // H2D's typical post-print state: one tray's filament is still threaded out
+    // into the feed tube even after the print finishes. tray_now=255 but
+    // this tray's state stays at 11. The whole AMS is mechanically locked.
+    const ams = [{ id: 0, tray: [{ state: 10 }, { state: 11 }, { state: 9 }, { state: 10 }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(true);
+  });
+
+  it('returns true when targeted AMS-B has a loaded tray (per-AMS isolation)', () => {
+    // AMS-A locked, AMS-B free; targeting AMS-A → true, targeting AMS-B → false
+    const ams = [
+      { id: 0, tray: [{ state: 11 }, { state: 9 }] },
+      { id: 1, tray: [{ state: 10 }, { state: 10 }] },
+    ];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(true);
+    expect(isTrayLoadedInThisAms(ams, 1)).toBe(false);
+  });
+
+  it('returns false when targeted AMS has no trays array', () => {
+    const ams = [{ id: 0 }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('treats missing state as not-loaded (conservative default-allow)', () => {
+    // If firmware doesn't report a state field, default to allowing rotate.
+    // The firmware-side dry_sf_reason check still rejects on the route side
+    // if rotation is actually impossible, so being lenient here is safe.
+    const ams = [{ id: 0, tray: [{ state: undefined }, { state: undefined }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('submission clamp: rotateTray collapses to false when gate is active', () => {
+    // Mirrors:  rotateTray: dryingRotateTray && !isTrayLoadedInThisAms
+    // A user enabling rotate before tray.state shifts to 11 (e.g. user loads
+    // filament from the AMS UI while popover is open) sees the toggle disable,
+    // and the submit also sends rotate_tray=false. Without the clamp, firmware
+    // would reject with dry_sf_reason=[3] (ConsumableAtAmsOutlet) post-click.
+    const userToggleState = true;
+    const ams = [{ id: 0, tray: [{ state: 11 }, { state: 10 }] }];
+    const trayLoaded = isTrayLoadedInThisAms(ams, 0);
+    const submittedValue = userToggleState && !trayLoaded;
+    expect(trayLoaded).toBe(true);
+    expect(submittedValue).toBe(false);
+  });
+
+  it('submission clamp: rotateTray passes through when gate is inactive', () => {
+    const userToggleState = true;
+    const ams = [{ id: 0, tray: [{ state: 10 }, { state: 10 }] }];
+    const trayLoaded = isTrayLoadedInThisAms(ams, 0);
+    const submittedValue = userToggleState && !trayLoaded;
+    expect(trayLoaded).toBe(false);
+    expect(submittedValue).toBe(true);
+  });
+});

+ 18 - 0
frontend/src/api/client.ts

@@ -366,6 +366,8 @@ export interface AMSUnit {
   dry_status: number;     // 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
   dry_sub_status: number; // 0=Off, 1=Heating, 2=Dehumidify
   dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
+  dry_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
+  dry_filament: string | null;    // Active-cycle filament name we sent
   module_type: string;    // "ams", "n3f", "n3s"
 }
 
@@ -1085,6 +1087,10 @@ export interface AppSettings {
   check_updates: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
+  // #1589: false hides the local username/password form on the login page;
+  // BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
+  // true so the env-var recovery path is visible to the SPA.
+  local_login_enabled: boolean;
   language: string;
   notification_language: string;
   // AMS threshold settings
@@ -1097,6 +1103,7 @@ export interface AppSettings {
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
   queue_drying_block: boolean;  // Block queue until drying completes
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
+  print_drying_enabled: boolean;  // Continue drying while a print is running on capable hardware
   drying_presets: string;  // JSON blob of drying presets per filament type
   ams_humidity_thresholds: string;  // JSON blob of per-filament humidity thresholds (#1605)
   gcode_snippets: string;  // JSON: per-model G-code injection snippets
@@ -3188,6 +3195,9 @@ export interface OIDCProvider {
   // includes this field in the response (Pydantic default-False is
   // populated unconditionally in the route handler).
   has_icon: boolean;
+  // #1589: when true, the LoginPage redirects unauthenticated visitors
+  // straight to this provider on mount. At most one provider may carry this.
+  is_autologin: boolean;
 }
 
 export interface OIDCProviderCreate {
@@ -3203,6 +3213,7 @@ export interface OIDCProviderCreate {
   require_email_verified?: boolean;
   icon_url?: string | null;
   default_group_id?: number | null;
+  is_autologin?: boolean;  // #1589
 }
 
 export interface OIDCLink {
@@ -3225,6 +3236,13 @@ export interface TestSMTPResponse {
 export interface AdvancedAuthStatus {
   advanced_auth_enabled: boolean;
   smtp_configured: boolean;
+  // #1589: false hides the username/password form on the LoginPage; the env
+  // var BAMBUDDY_LOCAL_LOGIN=true on the server flips this back to true so
+  // the recovery path remains visible.
+  local_login_enabled: boolean;
+  // #1589: when set, LoginPage redirects to this provider's authorize URL
+  // on mount unless ?fallback=local is in the URL or the redirect times out.
+  autologin_provider_id: number | null;
 }
 
 export interface LDAPStatus {

+ 148 - 0
frontend/src/components/CameraTile.tsx

@@ -0,0 +1,148 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { VideoOff, WifiOff } from 'lucide-react';
+import { getAuthToken, withStreamToken } from '../api/client';
+
+export type CameraTileMode = 'live' | 'snapshot' | 'paused';
+
+interface CameraTileProps {
+  printerId: number;
+  printerName: string;
+  cameraRotation?: number;
+  mode: CameraTileMode;
+  snapshotIntervalMs: number;
+  connected: boolean;
+  onClick?: () => void;
+}
+
+// Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
+// no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
+// still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
+const LIVE_FPS = 8;
+
+export function CameraTile({
+  printerId,
+  printerName,
+  cameraRotation = 0,
+  mode,
+  snapshotIntervalMs,
+  connected,
+  onClick,
+}: CameraTileProps) {
+  const { t } = useTranslation();
+  const [bust, setBust] = useState(0);
+  const [errored, setErrored] = useState(false);
+  const lastModeRef = useRef<CameraTileMode>(mode);
+
+  // Tell the backend to release its MJPEG transcoder when this tile stops
+  // being live — either by unmounting or by transitioning to snapshot/paused.
+  // EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount.
+  useEffect(() => {
+    const wasLive = lastModeRef.current === 'live';
+    const isLive = mode === 'live';
+    lastModeRef.current = mode;
+    if (wasLive && !isLive) {
+      const headers: Record<string, string> = {};
+      const token = getAuthToken();
+      if (token) headers['Authorization'] = `Bearer ${token}`;
+      fetch(`/api/v1/printers/${printerId}/camera/stop`, {
+        method: 'POST',
+        keepalive: true,
+        headers,
+      }).catch(() => {});
+    }
+    setErrored(false);
+    setBust((b) => b + 1);
+  }, [mode, printerId]);
+
+  useEffect(() => {
+    return () => {
+      if (lastModeRef.current === 'live') {
+        const headers: Record<string, string> = {};
+        const token = getAuthToken();
+        if (token) headers['Authorization'] = `Bearer ${token}`;
+        fetch(`/api/v1/printers/${printerId}/camera/stop`, {
+          method: 'POST',
+          keepalive: true,
+          headers,
+        }).catch(() => {});
+      }
+    };
+  }, [printerId]);
+
+  useEffect(() => {
+    if (mode !== 'snapshot') return;
+    const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs);
+    return () => clearInterval(interval);
+  }, [mode, snapshotIntervalMs]);
+
+  const liveUrl = withStreamToken(
+    `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
+  );
+  const snapshotUrl = withStreamToken(
+    `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
+  );
+
+  const handleClick = () => {
+    if (onClick) onClick();
+  };
+
+  const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
+
+  return (
+    <button
+      type="button"
+      onClick={handleClick}
+      className="group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left focus:outline-none focus:ring-2 focus:ring-bambu-green"
+      title={printerName}
+    >
+      {!connected || mode === 'paused' ? (
+        <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
+          {connected ? (
+            <VideoOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
+          ) : (
+            <WifiOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
+          )}
+        </div>
+      ) : errored ? (
+        <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/80 text-bambu-gray">
+          <VideoOff className="h-7 w-7" aria-hidden="true" />
+          <span className="text-xs">{t('printers.camWall.noSignal')}</span>
+        </div>
+      ) : (
+        <img
+          key={`${mode}-${bust}`}
+          src={mode === 'live' ? liveUrl : snapshotUrl}
+          alt={printerName}
+          draggable={false}
+          loading="lazy"
+          className="h-full w-full select-none object-contain"
+          style={{ transform }}
+          onError={() => setErrored(true)}
+        />
+      )}
+
+      {/* Mode indicator */}
+      <span
+        className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
+          mode === 'live'
+            ? 'bg-red-500/80 text-white'
+            : mode === 'snapshot'
+              ? 'bg-amber-500/70 text-black'
+              : 'bg-bambu-dark-tertiary/70 text-bambu-gray'
+        }`}
+      >
+        {mode === 'live'
+          ? t('printers.camWall.live')
+          : mode === 'snapshot'
+            ? t('printers.camWall.snap')
+            : t('printers.camWall.off')}
+      </span>
+
+      {/* Name overlay */}
+      <span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/80 to-transparent px-2 pb-1.5 pt-3 text-xs font-medium text-white">
+        {printerName}
+      </span>
+    </button>
+  );
+}

+ 216 - 0
frontend/src/components/CameraWall.tsx

@@ -0,0 +1,216 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQueries } from '@tanstack/react-query';
+import { Settings as SettingsIcon } from 'lucide-react';
+import { CameraTile, type CameraTileMode } from './CameraTile';
+import { api, type Printer } from '../api/client';
+
+interface CameraWallProps {
+  printers: Printer[];
+  maxLive: number;
+  snapshotIntervalSec: number;
+  onTileClick: (printerId: number, printerName: string) => void;
+  onChangeMaxLive: (next: number) => void;
+  onChangeSnapshotIntervalSec: (next: number) => void;
+}
+
+const MIN_MAX_LIVE = 1;
+const MAX_MAX_LIVE = 16;
+const MIN_SNAPSHOT_SEC = 2;
+const MAX_SNAPSHOT_SEC = 60;
+
+export function CameraWall({
+  printers,
+  maxLive,
+  snapshotIntervalSec,
+  onTileClick,
+  onChangeMaxLive,
+  onChangeSnapshotIntervalSec,
+}: CameraWallProps) {
+  const { t } = useTranslation();
+  const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
+
+  // Reuses the same ['printerStatus', id] cache that each PrinterCard
+  // populates, so flipping between Cards and Cam Wall is instant.
+  const statusQueries = useQueries({
+    queries: printers.map((p) => ({
+      queryKey: ['printerStatus', p.id],
+      queryFn: () => api.getPrinterStatus(p.id),
+      staleTime: 5000,
+    })),
+  });
+  const printerConnected = useMemo(() => {
+    const map = new Map<number, boolean>();
+    printers.forEach((p, i) => {
+      map.set(p.id, statusQueries[i]?.data?.connected ?? false);
+    });
+    return map;
+  }, [printers, statusQueries]);
+  const [visibleIds, setVisibleIds] = useState<Set<number>>(() => new Set());
+  const [showSettings, setShowSettings] = useState(false);
+  const settingsRef = useRef<HTMLDivElement | null>(null);
+
+  useEffect(() => {
+    if (!showSettings) return;
+    const handler = (e: MouseEvent) => {
+      if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
+        setShowSettings(false);
+      }
+    };
+    document.addEventListener('mousedown', handler);
+    return () => document.removeEventListener('mousedown', handler);
+  }, [showSettings]);
+
+  // IntersectionObserver: a tile is "visible" when ≥40% of it is on-screen.
+  // 40% (not 0%) avoids flicker at scroll boundaries where a tile is fractionally
+  // visible — we don't want to spin up a live stream for a 5-pixel sliver.
+  useEffect(() => {
+    const observer = new IntersectionObserver(
+      (entries) => {
+        setVisibleIds((prev) => {
+          const next = new Set(prev);
+          for (const entry of entries) {
+            const id = Number((entry.target as HTMLElement).dataset.printerId);
+            if (!Number.isFinite(id)) continue;
+            if (entry.isIntersecting) next.add(id);
+            else next.delete(id);
+          }
+          return next;
+        });
+      },
+      { threshold: 0.4 },
+    );
+
+    for (const [, el] of tileRefs.current) {
+      if (el) observer.observe(el);
+    }
+    return () => observer.disconnect();
+  }, [printers]);
+
+  // Live slot allocation: visible tiles get live up to `maxLive`, in printer
+  // list order so the assignment is stable. Visible-but-over-cap fall back to
+  // snapshot polling. Off-screen tiles render paused (no network).
+  const modeByPrinter = useMemo(() => {
+    const map = new Map<number, CameraTileMode>();
+    let liveBudget = Math.max(0, maxLive);
+    for (const p of printers) {
+      if (!visibleIds.has(p.id)) {
+        map.set(p.id, 'paused');
+        continue;
+      }
+      if (liveBudget > 0) {
+        map.set(p.id, 'live');
+        liveBudget -= 1;
+      } else {
+        map.set(p.id, 'snapshot');
+      }
+    }
+    return map;
+  }, [printers, visibleIds, maxLive]);
+
+  if (printers.length === 0) {
+    return (
+      <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-6 text-center text-bambu-gray">
+        {t('printers.camWall.noPrinters')}
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-3">
+      <div className="flex items-center justify-between text-xs text-bambu-gray">
+        <span>
+          {t('printers.camWall.summary', {
+            live: Array.from(modeByPrinter.values()).filter((m) => m === 'live').length,
+            snap: Array.from(modeByPrinter.values()).filter((m) => m === 'snapshot').length,
+            total: printers.length,
+          })}
+        </span>
+        <div className="relative" ref={settingsRef}>
+          <button
+            type="button"
+            onClick={() => setShowSettings((v) => !v)}
+            className="flex h-7 items-center gap-1 rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 text-white hover:bg-bambu-dark-tertiary"
+            title={t('printers.camWall.settings.title')}
+          >
+            <SettingsIcon className="h-3.5 w-3.5" />
+            <span>{t('printers.camWall.settings.title')}</span>
+          </button>
+          {showSettings && (
+            <div className="absolute right-0 top-9 z-30 w-72 space-y-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-3 shadow-xl">
+              <label className="block space-y-1">
+                <span className="text-xs font-medium text-white">
+                  {t('printers.camWall.settings.maxLive')}
+                </span>
+                <input
+                  type="number"
+                  min={MIN_MAX_LIVE}
+                  max={MAX_MAX_LIVE}
+                  value={maxLive}
+                  onChange={(e) => {
+                    const n = Math.min(
+                      MAX_MAX_LIVE,
+                      Math.max(MIN_MAX_LIVE, Number(e.target.value) || MIN_MAX_LIVE),
+                    );
+                    onChangeMaxLive(n);
+                  }}
+                  className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white"
+                />
+                <span className="block text-[11px] text-bambu-gray">
+                  {t('printers.camWall.settings.maxLiveHint')}
+                </span>
+              </label>
+              <label className="block space-y-1">
+                <span className="text-xs font-medium text-white">
+                  {t('printers.camWall.settings.snapshotInterval')}
+                </span>
+                <input
+                  type="number"
+                  min={MIN_SNAPSHOT_SEC}
+                  max={MAX_SNAPSHOT_SEC}
+                  value={snapshotIntervalSec}
+                  onChange={(e) => {
+                    const n = Math.min(
+                      MAX_SNAPSHOT_SEC,
+                      Math.max(MIN_SNAPSHOT_SEC, Number(e.target.value) || MIN_SNAPSHOT_SEC),
+                    );
+                    onChangeSnapshotIntervalSec(n);
+                  }}
+                  className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white"
+                />
+                <span className="block text-[11px] text-bambu-gray">
+                  {t('printers.camWall.settings.snapshotIntervalHint')}
+                </span>
+              </label>
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+        {printers.map((p) => {
+          const mode = modeByPrinter.get(p.id) ?? 'paused';
+          return (
+            <div
+              key={p.id}
+              ref={(el) => {
+                tileRefs.current.set(p.id, el);
+              }}
+              data-printer-id={p.id}
+            >
+              <CameraTile
+                printerId={p.id}
+                printerName={p.name}
+                cameraRotation={p.camera_rotation}
+                mode={mode}
+                snapshotIntervalMs={snapshotIntervalSec * 1000}
+                connected={printerConnected.get(p.id) ?? false}
+                onClick={() => onTileClick(p.id, p.name)}
+              />
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 9 - 0
frontend/src/components/OIDCProviderSettings.tsx

@@ -23,6 +23,7 @@ const EMPTY_FORM: OIDCProviderCreate = {
   require_email_verified: true,
   icon_url: undefined,
   default_group_id: null,
+  is_autologin: false,
 };
 
 // ─── Provider form (create / edit) ───────────────────────────────────────────
@@ -149,6 +150,13 @@ function ProviderForm({
             <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
           </div>
         </label>
+        <label className="flex items-center gap-3 cursor-pointer w-full">
+          <Toggle checked={form.is_autologin ?? false} onChange={(v) => set('is_autologin', v)} />
+          <div>
+            <p className="text-white text-sm">{t('settings.oidc.form.autologin')}</p>
+            <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autologinDesc')}</p>
+          </div>
+        </label>
       </div>
 
       <div>
@@ -447,6 +455,7 @@ export function OIDCProviderSettings() {
                     require_email_verified: provider.require_email_verified,
                     icon_url: provider.icon_url ?? undefined,
                     default_group_id: provider.default_group_id ?? null,
+                    is_autologin: provider.is_autologin,
                   }}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
                   onCancel={() => setEditingId(null)}

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

@@ -193,6 +193,25 @@ export default {
       large: 'Große Karten',
       extraLarge: 'Extra große Karten',
     },
+    pageView: {
+      cards: 'Karten',
+      camWall: 'Kamera-Wand',
+    },
+    camWall: {
+      noPrinters: 'Keine Drucker anzuzeigen',
+      noSignal: 'Kein Signal',
+      live: 'Live',
+      snap: 'Foto',
+      off: 'Aus',
+      summary: '{{live}} live, {{snap}} Schnappschüsse, {{total}} insgesamt',
+      settings: {
+        title: 'Kamera-Wand-Einstellungen',
+        maxLive: 'Max. Live-Streams',
+        maxLiveHint: 'Wie viele Kacheln gleichzeitig live streamen. Andere aktualisieren als Schnappschüsse.',
+        snapshotInterval: 'Schnappschuss-Intervall (Sekunden)',
+        snapshotIntervalHint: 'Wie oft Nicht-Live-Kacheln einen neuen Schnappschuss abrufen.',
+      },
+    },
     // Controls
     hideOffline: 'Offline ausblenden',
     nextAvailable: 'Nächster verfügbar',
@@ -535,11 +554,13 @@ export default {
       hours: 'Stunden',
       timeRemaining: '{{time}} verbleibend',
       active: 'Trocknung',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Trocknung nicht unterstützt',
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
       startingDrying: 'Trocknung wird gestartet...',
       stoppingDrying: 'Trocknung wird gestoppt...',
       rotateTray: 'Spule während der Trocknung drehen',
+      rotateUnavailableReason: 'Nicht verfügbar — in diesem AMS ist ein Slot zum Druckkopf hin geladen. Die Spule ist durch den Zuführschlauch blockiert und kann nicht rotieren. Filament zuerst zurückziehen.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup ist EIN. Zum Deaktivieren klicken.',
@@ -1844,6 +1865,10 @@ export default {
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
+    localLogin: {
+      disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
+      disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',
+    },
     // Queue
     enableRetry: 'Wiederholung aktivieren',
     // Home Assistant
@@ -2012,6 +2037,8 @@ export default {
     queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
     ambientDryingEnabled: 'Umgebungstrocknung',
     ambientDryingEnabledDescription: 'Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.',
+    printDryingEnabled: 'Trocknen während des Drucks',
+    printDryingEnabledDescription: 'Automatische Trocknung auch während eines laufenden Drucks auf unterstützter Hardware (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L mit aktueller Firmware). Die Trocknungstemperatur wird zum Schutz der Spulen automatisch um 5°C unter dem Leerlaufwert begrenzt.',
     dryingPresets: 'Trocknungsvoreinstellungen',
     dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.',
     dryingFilament: 'Filament',
@@ -2512,6 +2539,8 @@ export default {
         defaultGroup: 'Standardgruppe',
         defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
         defaultGroupViewersFallback: 'Viewers (Standard)',
+        autologin: 'Automatische Anmeldung',
+        autologinDesc: 'Nicht angemeldete Besucher direkt zu diesem Anbieter weiterleiten. Diese Option kann nur für einen Anbieter aktiv sein.',
       },
     },
 
@@ -2660,6 +2689,8 @@ export default {
     signingIn: 'Anmeldung läuft...',
     rememberMe: 'Angemeldet bleiben',
     forgotPassword: 'Passwort vergessen?',
+    autologinFailed: 'Automatische SSO-Anmeldung fehlgeschlagen. Bitte wählen Sie unten einen Anbieter.',
+    localDisabledNotice: 'Lokale Anmeldung ist deaktiviert. Bitte verwenden Sie einen der SSO-Anbieter unten.',
     loginSuccess: 'Erfolgreich angemeldet',
     loginFailed: 'Anmeldung fehlgeschlagen',
     enterCredentials: 'Bitte Benutzername und Passwort eingeben',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Large cards',
       extraLarge: 'Extra large cards',
     },
+    pageView: {
+      cards: 'Cards',
+      camWall: 'Cam wall',
+    },
+    camWall: {
+      noPrinters: 'No printers to show',
+      noSignal: 'No signal',
+      live: 'Live',
+      snap: 'Snap',
+      off: 'Off',
+      summary: '{{live}} live, {{snap}} snapshots, {{total}} total',
+      settings: {
+        title: 'Cam wall settings',
+        maxLive: 'Max live streams',
+        maxLiveHint: 'How many tiles stream live at once. Others refresh as snapshots.',
+        snapshotInterval: 'Snapshot interval (seconds)',
+        snapshotIntervalHint: 'How often non-live tiles fetch a fresh snapshot.',
+      },
+    },
     // Controls
     hideOffline: 'Hide offline',
     nextAvailable: 'Next available',
@@ -538,11 +557,13 @@ export default {
       hours: 'hours',
       timeRemaining: '{{time}} left',
       active: 'Drying',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Drying not supported',
       powerRequired: 'Connect AMS power adapter to enable drying',
       startingDrying: 'Starting drying...',
       stoppingDrying: 'Stopping drying...',
       rotateTray: 'Rotate spool during drying',
+      rotateUnavailableReason: 'Unavailable — a slot in this AMS is loaded to the toolhead. The spool is locked by the feed tube and cannot rotate. Retract the filament first.',
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)
     amsBackup: {
@@ -1858,6 +1879,10 @@ export default {
     checkPrinterFirmware: 'Check printer firmware',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
+    localLogin: {
+      disable: 'Disable local username/password login',
+      disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',
+    },
     // Queue
     enableRetry: 'Enable retry',
     // Home Assistant
@@ -2026,6 +2051,8 @@ export default {
     queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
     ambientDryingEnabled: 'Ambient drying',
     ambientDryingEnabledDescription: 'Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.',
+    printDryingEnabled: 'Continue drying while printing',
+    printDryingEnabledDescription: 'Allow auto-drying to keep running during a print on supported hardware (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L on recent firmware). Drying temperature is automatically capped 5°C below the idle preset to protect spools.',
     dryingPresets: 'Drying Presets',
     dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.',
     dryingFilament: 'Filament',
@@ -2527,6 +2554,8 @@ export default {
         defaultGroup: 'Default Group',
         defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
         defaultGroupViewersFallback: 'Viewers (default)',
+        autologin: 'Autologin',
+        autologinDesc: 'Redirect unauthenticated visitors straight to this provider. Only one provider can carry this flag.',
       },
     },
 
@@ -2675,6 +2704,8 @@ export default {
     signingIn: 'Logging in...',
     rememberMe: 'Remember Me',
     forgotPassword: 'Forgot your password?',
+    autologinFailed: 'Automatic SSO sign-in failed. Pick a provider below to continue.',
+    localDisabledNotice: 'Local sign-in is disabled. Use one of the SSO providers below.',
     loginSuccess: 'Logged in successfully',
     loginFailed: 'Login failed',
     enterCredentials: 'Please enter username and password',

+ 31 - 0
frontend/src/i18n/locales/es.ts

@@ -193,6 +193,25 @@ export default {
       large: 'Tarjetas grandes',
       extraLarge: 'Tarjetas extragrandes',
     },
+    pageView: {
+      cards: 'Tarjetas',
+      camWall: 'Muro de cámaras',
+    },
+    camWall: {
+      noPrinters: 'No hay impresoras que mostrar',
+      noSignal: 'Sin señal',
+      live: 'En vivo',
+      snap: 'Foto',
+      off: 'Inactivo',
+      summary: '{{live}} en vivo, {{snap}} fotos, {{total}} en total',
+      settings: {
+        title: 'Ajustes del muro de cámaras',
+        maxLive: 'Máx. transmisiones en vivo',
+        maxLiveHint: 'Cuántos mosaicos transmiten en vivo a la vez. Los demás se actualizan como fotos.',
+        snapshotInterval: 'Intervalo de fotos (segundos)',
+        snapshotIntervalHint: 'Con qué frecuencia los mosaicos no en vivo obtienen una nueva foto.',
+      },
+    },
     // Controls
     hideOffline: 'Ocultar desconectadas',
     nextAvailable: 'Próxima disponible',
@@ -535,11 +554,13 @@ export default {
       hours: 'horas',
       timeRemaining: '{{time}} restante',
       active: 'Secando',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secado no compatible',
       powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',
       startingDrying: 'Iniciando el secado...',
       stoppingDrying: 'Deteniendo el secado...',
       rotateTray: 'Girar la bobina durante el secado',
+      rotateUnavailableReason: 'No disponible — un slot de este AMS está cargado hacia el cabezal. La bobina está bloqueada por el tubo de alimentación y no puede girar. Retira el filamento primero.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup está ACTIVADO. Haz clic para desactivar.',
@@ -1847,6 +1868,10 @@ export default {
     checkPrinterFirmware: 'Comprobar el firmware de la impresora',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
+    localLogin: {
+      disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
+      disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',
+    },
     // Queue
     enableRetry: 'Activar reintentos',
     // Home Assistant
@@ -2015,6 +2040,8 @@ export default {
     queueDryingBlockDescription: 'Bloquear la cola de impresión hasta que termine el secado. Cuando está desactivado, las impresiones tienen prioridad sobre el secado.',
     ambientDryingEnabled: 'Secado ambiental',
     ambientDryingEnabledDescription: 'Secar automáticamente el filamento en impresoras inactivas cuando la humedad supera el umbral, incluso sin impresiones en cola.',
+    printDryingEnabled: 'Continuar secado durante la impresión',
+    printDryingEnabledDescription: 'Permite que el secado automático siga funcionando durante una impresión en hardware compatible (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L con firmware reciente). La temperatura de secado se limita automáticamente 5°C por debajo del preajuste en reposo para proteger las bobinas.',
     dryingPresets: 'Preajustes de secado',
     dryingPresetsDescription: 'Temperatura y duración por tipo de filamento. El AMS 2 Pro usa temperaturas más bajas; el AMS-HT admite temperaturas más altas.',
     dryingFilament: 'Filamento',
@@ -2515,6 +2542,8 @@ export default {
         defaultGroup: 'Grupo predeterminado',
         defaultGroupDesc: 'Grupo asignado a los usuarios creados automáticamente. Si no se establece, se usa Visores como alternativa.',
         defaultGroupViewersFallback: 'Visores (predeterminado)',
+        autologin: 'Inicio automático',
+        autologinDesc: 'Redirigir a los visitantes no autenticados directamente a este proveedor. Solo un proveedor puede llevar esta marca.',
       },
     },
 
@@ -2663,6 +2692,8 @@ export default {
     signingIn: 'Iniciando sesión...',
     rememberMe: 'Recordarme',
     forgotPassword: '¿Olvidó su contraseña?',
+    autologinFailed: 'El inicio de sesión SSO automático falló. Elija un proveedor abajo para continuar.',
+    localDisabledNotice: 'El inicio de sesión local está deshabilitado. Use uno de los proveedores SSO de abajo.',
     loginSuccess: 'Sesión iniciada correctamente',
     loginFailed: 'Error al iniciar sesión',
     enterCredentials: 'Introduzca el nombre de usuario y la contraseña',

+ 31 - 0
frontend/src/i18n/locales/fr.ts

@@ -193,6 +193,25 @@ export default {
       large: 'Grandes cartes',
       extraLarge: 'Très grandes cartes',
     },
+    pageView: {
+      cards: 'Cartes',
+      camWall: 'Mur de caméras',
+    },
+    camWall: {
+      noPrinters: 'Aucune imprimante à afficher',
+      noSignal: 'Aucun signal',
+      live: 'En direct',
+      snap: 'Photo',
+      off: 'Arrêt',
+      summary: '{{live}} en direct, {{snap}} captures, {{total}} au total',
+      settings: {
+        title: 'Paramètres du mur de caméras',
+        maxLive: 'Flux en direct max.',
+        maxLiveHint: 'Combien de vignettes diffusent en direct à la fois. Les autres se rafraîchissent en captures.',
+        snapshotInterval: 'Intervalle de capture (secondes)',
+        snapshotIntervalHint: 'À quelle fréquence les vignettes hors direct récupèrent une nouvelle capture.',
+      },
+    },
     // Controls
     hideOffline: 'Masquer hors ligne',
     nextAvailable: 'Prochaine disponible',
@@ -535,11 +554,13 @@ export default {
       hours: 'heures',
       timeRemaining: '{{time}} restant',
       active: 'Séchage',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Séchage non pris en charge',
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
       startingDrying: 'Démarrage du séchage...',
       stoppingDrying: 'Arrêt du séchage...',
       rotateTray: 'Tourner la bobine pendant le séchage',
+      rotateUnavailableReason: 'Indisponible — un emplacement de cet AMS est chargé vers la tête d\'impression. La bobine est bloquée par le tube d\'alimentation et ne peut pas tourner. Rétractez d\'abord le filament.',
     },
     amsBackup: {
       titleOn: "AMS Filament Backup est ACTIVÉ. Cliquez pour désactiver.",
@@ -1800,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Vérifier le firmware imprimante',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
+    localLogin: {
+      disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
+      disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',
+    },
     // Queue
     enableRetry: 'Activer la rétentative',
     // Home Assistant
@@ -1968,6 +1993,8 @@ export default {
     queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
     ambientDryingEnabled: 'Séchage ambiant',
     ambientDryingEnabledDescription: 'Sécher automatiquement le filament sur les imprimantes inactives lorsque l\'humidité dépasse le seuil, même sans impressions en file.',
+    printDryingEnabled: 'Séchage pendant l\'impression',
+    printDryingEnabledDescription: 'Autorise le séchage automatique à continuer pendant une impression sur le matériel pris en charge (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L avec firmware récent). La température de séchage est automatiquement limitée à 5°C sous le préréglage en attente pour protéger les bobines.',
     dryingPresets: 'Préréglages de séchage',
     dryingPresetsDescription: 'Température et durée par type de filament. AMS 2 Pro utilise des températures plus basses, AMS-HT supporte des températures plus élevées.',
     dryingFilament: 'Filament',
@@ -2455,6 +2482,8 @@ export default {
         defaultGroup: 'Groupe par défaut',
         defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
         defaultGroupViewersFallback: 'Viewers (par défaut)',
+        autologin: 'Connexion automatique',
+        autologinDesc: 'Rediriger les visiteurs non authentifiés directement vers ce fournisseur. Un seul fournisseur peut porter cet indicateur.',
       },
     },
 
@@ -2649,6 +2678,8 @@ export default {
     signingIn: 'Connexion...',
     rememberMe: 'Se souvenir de moi',
     forgotPassword: 'Mot de passe oublié ?',
+    autologinFailed: 'La connexion SSO automatique a échoué. Choisissez un fournisseur ci-dessous pour continuer.',
+    localDisabledNotice: 'La connexion locale est désactivée. Utilisez l\'un des fournisseurs SSO ci-dessous.',
     loginSuccess: 'Connecté avec succès',
     loginFailed: 'Échec de connexion',
     enterCredentials: 'Entrez vos identifiants',

+ 31 - 0
frontend/src/i18n/locales/it.ts

@@ -193,6 +193,25 @@ export default {
       large: 'Schede grandi',
       extraLarge: 'Schede extra grandi',
     },
+    pageView: {
+      cards: 'Schede',
+      camWall: 'Muro telecamere',
+    },
+    camWall: {
+      noPrinters: 'Nessuna stampante da mostrare',
+      noSignal: 'Nessun segnale',
+      live: 'Live',
+      snap: 'Foto',
+      off: 'Spento',
+      summary: '{{live}} live, {{snap}} foto, {{total}} totali',
+      settings: {
+        title: 'Impostazioni muro telecamere',
+        maxLive: 'Max stream live',
+        maxLiveHint: 'Quante tessere trasmettono in live contemporaneamente. Le altre si aggiornano come foto.',
+        snapshotInterval: 'Intervallo foto (secondi)',
+        snapshotIntervalHint: 'Con quale frequenza le tessere non live scaricano una nuova foto.',
+      },
+    },
     // Controls
     hideOffline: 'Nascondi offline',
     nextAvailable: 'Prossima disponibile',
@@ -535,11 +554,13 @@ export default {
       hours: 'ore',
       timeRemaining: '{{time}} rimanente',
       active: 'Essiccazione',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Essiccazione non supportata',
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
       startingDrying: 'Avvio essiccazione...',
       stoppingDrying: 'Arresto essiccazione...',
       rotateTray: 'Ruota la bobina durante l\'essiccazione',
+      rotateUnavailableReason: 'Non disponibile — uno slot di questo AMS è caricato verso la testa di stampa. La bobina è bloccata dal tubo di alimentazione e non può ruotare. Ritrai prima il filamento.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup è ATTIVO. Clicca per disabilitare.',
@@ -1800,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Controlla firmware stampante',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
+    localLogin: {
+      disable: 'Disabilita l\'accesso locale con nome utente/password',
+      disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',
+    },
     // Queue
     enableRetry: 'Abilita retry',
     // Home Assistant
@@ -1968,6 +1993,8 @@ export default {
     queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
     ambientDryingEnabled: 'Asciugatura ambientale',
     ambientDryingEnabledDescription: 'Asciuga automaticamente il filamento sulle stampanti inattive quando l\'umidità supera la soglia, anche senza stampe in coda.',
+    printDryingEnabled: 'Asciugatura durante la stampa',
+    printDryingEnabledDescription: 'Consente all\'asciugatura automatica di continuare durante una stampa su hardware supportato (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L con firmware recente). La temperatura di asciugatura viene automaticamente limitata a 5°C sotto il preset di riposo per proteggere le bobine.',
     dryingPresets: 'Preset di asciugatura',
     dryingPresetsDescription: 'Temperatura e durata per tipo di filamento. AMS 2 Pro usa temperature più basse, AMS-HT supporta temperature più alte.',
     dryingFilament: 'Filamento',
@@ -2454,6 +2481,8 @@ export default {
         defaultGroup: 'Gruppo predefinito',
         defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
         defaultGroupViewersFallback: 'Viewers (predefinito)',
+        autologin: 'Accesso automatico',
+        autologinDesc: 'Reindirizza i visitatori non autenticati direttamente a questo provider. Solo un provider può avere questo flag.',
       },
     },
 
@@ -2648,6 +2677,8 @@ export default {
     signingIn: 'Accesso in corso...',
     rememberMe: 'Ricordami',
     forgotPassword: 'Hai dimenticato la password?',
+    autologinFailed: 'Accesso SSO automatico fallito. Scegli un provider qui sotto per continuare.',
+    localDisabledNotice: 'L\'accesso locale è disabilitato. Usa uno dei provider SSO qui sotto.',
     loginSuccess: 'Accesso riuscito',
     loginFailed: 'Accesso fallito',
     enterCredentials: 'Inserisci nome utente e password',

+ 31 - 0
frontend/src/i18n/locales/ja.ts

@@ -192,6 +192,25 @@ export default {
       large: '大',
       extraLarge: '特大',
     },
+    pageView: {
+      cards: 'カード',
+      camWall: 'カメラウォール',
+    },
+    camWall: {
+      noPrinters: '表示するプリンターがありません',
+      noSignal: '信号なし',
+      live: 'ライブ',
+      snap: 'スナップ',
+      off: 'オフ',
+      summary: 'ライブ {{live}}件、スナップ {{snap}}件、合計 {{total}}件',
+      settings: {
+        title: 'カメラウォール設定',
+        maxLive: '最大ライブ配信数',
+        maxLiveHint: '同時にライブ配信するタイル数。残りはスナップショットとして更新されます。',
+        snapshotInterval: 'スナップショット間隔(秒)',
+        snapshotIntervalHint: '非ライブのタイルが新しいスナップショットを取得する頻度。',
+      },
+    },
     // Controls
     hideOffline: 'オフラインを非表示',
     nextAvailable: '次に完了',
@@ -534,11 +553,13 @@ export default {
       hours: '時間',
       timeRemaining: '残り {{time}}',
       active: '乾燥中',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '乾燥非対応',
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
       startingDrying: '乾燥を開始しています...',
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
+      rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
     },
     amsBackup: {
       titleOn: 'AMSフィラメントバックアップはONです。クリックして無効化します。',
@@ -1843,6 +1864,10 @@ export default {
     checkPrinterFirmware: 'プリンターファームウェアの確認',
     includeBetaUpdates: 'ベータ版を含める',
     includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
+    localLogin: {
+      disable: 'ローカルのユーザー名/パスワードログインを無効化',
+      disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',
+    },
     // Queue
     enableRetry: 'リトライを有効化',
     // Home Assistant
@@ -2011,6 +2036,8 @@ export default {
     queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
     ambientDryingEnabled: '常時乾燥',
     ambientDryingEnabledDescription: 'キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。',
+    printDryingEnabled: '印刷中も乾燥を継続',
+    printDryingEnabledDescription: '対応ハードウェア(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L、最新ファームウェア)で印刷中も自動乾燥を継続します。スプール保護のため、乾燥温度はアイドル時のプリセットより自動的に5°C低く制限されます。',
     dryingPresets: '乾燥プリセット',
     dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
     dryingFilament: 'フィラメント',
@@ -2511,6 +2538,8 @@ export default {
         defaultGroup: 'デフォルトグループ',
         defaultGroupDesc: '自動作成ユーザーに割り当てられるグループ。未設定の場合はViewersにフォールバックします。',
         defaultGroupViewersFallback: 'Viewers(デフォルト)',
+        autologin: '自動サインイン',
+        autologinDesc: '未認証の訪問者をこのプロバイダーに直接リダイレクトします。このフラグを付けられるプロバイダーは1つだけです。',
       },
     },
 
@@ -2660,6 +2689,8 @@ export default {
     signingIn: 'ログイン中...',
     rememberMe: 'ログイン状態を保持する',
     forgotPassword: 'パスワードをお忘れですか?',
+    autologinFailed: 'SSOへの自動サインインに失敗しました。下から続行するプロバイダーを選択してください。',
+    localDisabledNotice: 'ローカルサインインは無効化されています。下のSSOプロバイダーをご利用ください。',
     loginSuccess: 'ログインしました',
     loginFailed: 'ログインに失敗しました',
     enterCredentials: 'ユーザー名とパスワードを入力してください',

+ 33 - 2
frontend/src/i18n/locales/ko.ts

@@ -180,6 +180,25 @@ export default {
       large: '큰 카드',
       extraLarge: '아주 큰 카드'
     },
+    pageView: {
+      cards: '카드',
+      camWall: '카메라 월'
+    },
+    camWall: {
+      noPrinters: '표시할 프린터가 없습니다',
+      noSignal: '신호 없음',
+      live: '라이브',
+      snap: '스냅',
+      off: '꺼짐',
+      summary: '라이브 {{live}}개, 스냅 {{snap}}개, 총 {{total}}개',
+      settings: {
+        title: '카메라 월 설정',
+        maxLive: '최대 라이브 스트림',
+        maxLiveHint: '동시에 라이브 스트리밍할 타일 수. 나머지는 스냅샷으로 갱신됩니다.',
+        snapshotInterval: '스냅샷 간격(초)',
+        snapshotIntervalHint: '비라이브 타일이 새 스냅샷을 가져오는 주기.'
+      }
+    },
     hideOffline: '오프라인 숨기기',
     nextAvailable: '다음 가용',
     powerOn: '전원 켜기',
@@ -498,11 +517,13 @@ export default {
       hours: '시간',
       timeRemaining: '{{time}} 남음',
       active: '건조 중',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '건조 지원 안 됨',
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
       startingDrying: '건조 시작 중...',
       stoppingDrying: '건조 정지 중...',
-      rotateTray: '건조 중 스풀 회전'
+      rotateTray: '건조 중 스풀 회전',
+      rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'
     },
     amsBackup: {
       titleOn: 'AMS 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.',
@@ -1744,6 +1765,10 @@ export default {
     checkPrinterFirmware: '프린터 펌웨어 확인',
     includeBetaUpdates: '베타 버전 포함',
     includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
+    localLogin: {
+      disable: '로컬 사용자명/비밀번호 로그인 비활성화',
+      disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'
+    },
     enableRetry: '재시도 활성화',
     homeAssistantDescription: 'Home Assistant를 통해 스마트 플러그 제어',
     environmentManagedLabel: '(환경 변수 관리)',
@@ -1894,6 +1919,8 @@ export default {
     queueDryingBlockDescription: '건조가 완료될 때까지 인쇄 대기열을 차단합니다. 끄면 인쇄가 건조보다 우선합니다.',
     ambientDryingEnabled: '주변 건조',
     ambientDryingEnabledDescription: '대기 중인 인쇄가 없어도 습도가 임계값을 초과하면 유휴 프린터에서 자동으로 필라멘트 건조',
+    printDryingEnabled: '인쇄 중 건조 계속',
+    printDryingEnabledDescription: '지원되는 하드웨어(H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L 최신 펌웨어)에서 인쇄 중에도 자동 건조를 계속 실행합니다. 스풀 보호를 위해 건조 온도가 유휴 프리셋보다 자동으로 5°C 낮게 제한됩니다.',
     dryingPresets: '건조 프리셋',
     dryingPresetsDescription: '필라멘트 유형별 온도 및 시간. AMS 2 Pro는 낮은 온도, AMS-HT는 높은 온도를 지원합니다.',
     dryingFilament: '필라멘트',
@@ -2361,7 +2388,9 @@ export default {
         requireEmailVerifiedAutoLink: '이 설정을 변경하려면 먼저 자동 연결을 비활성화하세요.',
         defaultGroup: '기본 그룹',
         defaultGroupDesc: '자동 생성된 사용자에게 할당되는 그룹. 설정되지 않으면 Viewers로 대체됩니다.',
-        defaultGroupViewersFallback: 'Viewers (기본값)'
+        defaultGroupViewersFallback: 'Viewers (기본값)',
+        autologin: '자동 로그인',
+        autologinDesc: '인증되지 않은 방문자를 이 공급자로 바로 리디렉션합니다. 이 플래그를 가질 수 있는 공급자는 하나뿐입니다.'
       },
       refreshIcon: '아이콘 새로고침',
       removeIcon: '아이콘 제거',
@@ -2503,6 +2532,8 @@ export default {
     signingIn: '로그인 중...',
     rememberMe: '로그인 유지',
     forgotPassword: '비밀번호를 잊으셨나요?',
+    autologinFailed: 'SSO 자동 로그인에 실패했습니다. 아래에서 계속할 공급자를 선택하세요.',
+    localDisabledNotice: '로컬 로그인이 비활성화되어 있습니다. 아래 SSO 공급자 중 하나를 사용하세요.',
     loginSuccess: '성공적으로 로그인되었습니다',
     loginFailed: '로그인 실패',
     enterCredentials: '사용자명과 비밀번호를 입력하세요',

+ 31 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -193,6 +193,25 @@ export default {
       large: 'Cartões grandes',
       extraLarge: 'Cartões extra grandes',
     },
+    pageView: {
+      cards: 'Cartões',
+      camWall: 'Mural de câmeras',
+    },
+    camWall: {
+      noPrinters: 'Nenhuma impressora para exibir',
+      noSignal: 'Sem sinal',
+      live: 'Ao vivo',
+      snap: 'Foto',
+      off: 'Desligado',
+      summary: '{{live}} ao vivo, {{snap}} fotos, {{total}} no total',
+      settings: {
+        title: 'Configurações do mural de câmeras',
+        maxLive: 'Máx. transmissões ao vivo',
+        maxLiveHint: 'Quantos blocos transmitem ao vivo simultaneamente. Os demais atualizam como fotos.',
+        snapshotInterval: 'Intervalo de foto (segundos)',
+        snapshotIntervalHint: 'Com que frequência os blocos não ao vivo buscam uma nova foto.',
+      },
+    },
     // Controls
     hideOffline: 'Ocultar offline',
     nextAvailable: 'Próximo disponível',
@@ -535,11 +554,13 @@ export default {
       hours: 'horas',
       timeRemaining: '{{time}} restante',
       active: 'Secagem',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secagem não suportada',
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
       startingDrying: 'Iniciando secagem...',
       stoppingDrying: 'Parando secagem...',
       rotateTray: 'Girar o carretel durante a secagem',
+      rotateUnavailableReason: 'Indisponível — um slot deste AMS está carregado em direção ao cabeçote. O carretel está travado pelo tubo de alimentação e não pode girar. Retraia o filamento primeiro.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup está LIGADO. Clique para desativar.',
@@ -1800,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Verificar firmware da impressora',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
+    localLogin: {
+      disable: 'Desativar login local com usuário/senha',
+      disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',
+    },
     // Queue
     enableRetry: 'Habilitar tentativa',
     // Home Assistant
@@ -1968,6 +1993,8 @@ export default {
     queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
     ambientDryingEnabled: 'Secagem ambiente',
     ambientDryingEnabledDescription: 'Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.',
+    printDryingEnabled: 'Continuar secagem durante a impressão',
+    printDryingEnabledDescription: 'Permite que a secagem automática continue funcionando durante uma impressão em hardware compatível (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L com firmware recente). A temperatura de secagem é limitada automaticamente 5°C abaixo do valor de inatividade para proteger as bobinas.',
     dryingPresets: 'Predefinições de secagem',
     dryingPresetsDescription: 'Temperatura e duração por tipo de filamento. AMS 2 Pro usa temperaturas mais baixas, AMS-HT suporta temperaturas mais altas.',
     dryingFilament: 'Filamento',
@@ -2454,6 +2481,8 @@ export default {
         defaultGroup: 'Grupo padrão',
         defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
         defaultGroupViewersFallback: 'Viewers (padrão)',
+        autologin: 'Login automático',
+        autologinDesc: 'Redirecionar visitantes não autenticados diretamente para este provedor. Apenas um provedor pode ter esta marcação.',
       },
     },
 
@@ -2648,6 +2677,8 @@ export default {
     signingIn: 'Entrando...',
     rememberMe: 'Lembrar de mim',
     forgotPassword: 'Esqueceu sua senha?',
+    autologinFailed: 'O login SSO automático falhou. Escolha um provedor abaixo para continuar.',
+    localDisabledNotice: 'O login local está desativado. Use um dos provedores SSO abaixo.',
     loginSuccess: 'Login realizado com sucesso',
     loginFailed: 'Falha no login',
     enterCredentials: 'Por favor, insira nome de usuário e senha',

+ 31 - 0
frontend/src/i18n/locales/tr.ts

@@ -193,6 +193,25 @@ export default {
       large: 'Büyük kartlar',
       extraLarge: 'Çok büyük kartlar',
     },
+    pageView: {
+      cards: 'Kartlar',
+      camWall: 'Kamera duvarı',
+    },
+    camWall: {
+      noPrinters: 'Gösterilecek yazıcı yok',
+      noSignal: 'Sinyal yok',
+      live: 'Canlı',
+      snap: 'Foto',
+      off: 'Kapalı',
+      summary: '{{live}} canlı, {{snap}} fotoğraf, toplam {{total}}',
+      settings: {
+        title: 'Kamera duvarı ayarları',
+        maxLive: 'Maks. canlı yayın',
+        maxLiveHint: 'Aynı anda kaç döşemenin canlı yayın yaptığı. Diğerleri foto olarak yenilenir.',
+        snapshotInterval: 'Foto aralığı (saniye)',
+        snapshotIntervalHint: 'Canlı olmayan döşemelerin ne sıklıkla yeni bir foto aldığı.',
+      },
+    },
     // Kontroller
     hideOffline: 'Çevrimdışı olanları gizle',
     nextAvailable: 'Sıradaki müsait',
@@ -535,11 +554,13 @@ export default {
       hours: 'saat',
       timeRemaining: '{{time}} kaldı',
       active: 'Kurutuluyor',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Kurutma desteklenmiyor',
       powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',
       startingDrying: 'Kurutma başlatılıyor...',
       stoppingDrying: 'Kurutma durduruluyor...',
       rotateTray: 'Kurutma sırasında makarayı döndür',
+      rotateUnavailableReason: 'Kullanılamaz — bu AMS\'nin bir yuvası kafaya doğru yüklenmiş durumda. Makara besleme borusu tarafından kilitlendiği için döndürülemez. Önce filamenti geri çekin.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup AÇIK. Devre dışı bırakmak için tıklayın.',
@@ -1847,6 +1868,10 @@ export default {
     checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
+    localLogin: {
+      disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
+      disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',
+    },
     // Kuyruk
     enableRetry: 'Yeniden denemeyi etkinleştir',
     // Home Assistant
@@ -2015,6 +2040,8 @@ export default {
     queueDryingBlockDescription: 'Kurutma bitene kadar baskı kuyruğunu engelle. Kapalıyken, baskılar kurutmadan önceliklidir.',
     ambientDryingEnabled: 'Ortam kurutma',
     ambientDryingEnabledDescription: 'Kuyrukta baskı olmasa bile, nem eşiği aştığında boşta yazıcılarda filamenti otomatik olarak kurut.',
+    printDryingEnabled: 'Baskı sırasında kurutmaya devam et',
+    printDryingEnabledDescription: 'Desteklenen donanımda (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L güncel firmware ile) baskı sırasında otomatik kurutmanın çalışmaya devam etmesine izin verir. Makara koruması için kurutma sıcaklığı otomatik olarak boştaki ön ayarın 5°C altına sınırlandırılır.',
     dryingPresets: 'Kurutma Ön Ayarları',
     dryingPresetsDescription: 'Filament türü başına sıcaklık ve süre. AMS 2 Pro daha düşük sıcaklıklar kullanır, AMS-HT daha yüksek sıcaklıkları destekler.',
     dryingFilament: 'Filament',
@@ -2515,6 +2542,8 @@ export default {
         defaultGroup: 'Varsayılan Grup',
         defaultGroupDesc: 'Otomatik oluşturulan kullanıcılara atanan grup. Ayarlanmazsa Viewers\'a geri döner.',
         defaultGroupViewersFallback: 'Viewers (varsayılan)',
+        autologin: 'Otomatik oturum açma',
+        autologinDesc: 'Kimlik doğrulaması yapılmamış ziyaretçileri doğrudan bu sağlayıcıya yönlendir. Bu işareti yalnızca bir sağlayıcı taşıyabilir.',
       },
     },
 
@@ -2663,6 +2692,8 @@ export default {
     signingIn: 'Giriş yapılıyor...',
     rememberMe: 'Beni Hatırla',
     forgotPassword: 'Parolanızı mı unuttunuz?',
+    autologinFailed: 'Otomatik SSO girişi başarısız oldu. Devam etmek için aşağıdan bir sağlayıcı seçin.',
+    localDisabledNotice: 'Yerel oturum açma devre dışı. Aşağıdaki SSO sağlayıcılarından birini kullanın.',
     loginSuccess: 'Başarıyla giriş yapıldı',
     loginFailed: 'Giriş başarısız',
     enterCredentials: 'Lütfen kullanıcı adı ve parola girin',

+ 31 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -193,6 +193,25 @@ export default {
       large: '大卡片',
       extraLarge: '超大卡片',
     },
+    pageView: {
+      cards: '卡片',
+      camWall: '摄像头墙',
+    },
+    camWall: {
+      noPrinters: '没有可显示的打印机',
+      noSignal: '无信号',
+      live: '直播',
+      snap: '快照',
+      off: '关闭',
+      summary: '直播 {{live}} 个,快照 {{snap}} 个,共 {{total}} 个',
+      settings: {
+        title: '摄像头墙设置',
+        maxLive: '最大直播数',
+        maxLiveHint: '同时直播的画面数量。其他画面以快照刷新。',
+        snapshotInterval: '快照刷新间隔(秒)',
+        snapshotIntervalHint: '非直播画面获取新快照的频率。',
+      },
+    },
     // Controls
     hideOffline: '隐藏离线',
     nextAvailable: '下一个可用',
@@ -535,11 +554,13 @@ export default {
       hours: '小时',
       timeRemaining: '剩余 {{time}}',
       active: '干燥中',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支持干燥',
       powerRequired: '连接AMS电源适配器以启用干燥',
       startingDrying: '正在启动干燥...',
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
+      rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
     },
     amsBackup: {
       titleOn: 'AMS 备用料盘已开启。点击以禁用。',
@@ -1845,6 +1866,10 @@ export default {
     checkPrinterFirmware: '检查打印机固件',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
+    localLogin: {
+      disable: '禁用本地用户名/密码登录',
+      disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',
+    },
     // Queue
     enableRetry: '启用重试',
     // Home Assistant
@@ -2013,6 +2038,8 @@ export default {
     queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
     ambientDryingEnabled: '环境干燥',
     ambientDryingEnabledDescription: '当空闲打印机的湿度超过阈值时自动干燥耗材,无需排队打印。',
+    printDryingEnabled: '打印时继续干燥',
+    printDryingEnabledDescription: '允许自动干燥在支持的硬件(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新固件)打印过程中继续运行。为保护料盘,干燥温度会自动比空闲时预设低 5°C。',
     dryingPresets: '干燥预设',
     dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
     dryingFilament: '耗材',
@@ -2499,6 +2526,8 @@ export default {
         defaultGroup: '默认组',
         defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
         defaultGroupViewersFallback: 'Viewers(默认)',
+        autologin: '自动登录',
+        autologinDesc: '将未认证的访问者直接重定向到该提供商。只有一个提供商可以携带此标志。',
       },
     },
 
@@ -2648,6 +2677,8 @@ export default {
     signingIn: '登录中...',
     rememberMe: '记住我',
     forgotPassword: '忘记密码?',
+    autologinFailed: '自动SSO登录失败。请在下方选择一个提供商以继续。',
+    localDisabledNotice: '本地登录已禁用。请使用下方的SSO提供商之一。',
     loginSuccess: '登录成功',
     loginFailed: '登录失败',
     enterCredentials: '请输入用户名和密码',

+ 31 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -193,6 +193,25 @@ export default {
       large: '大卡片',
       extraLarge: '超大卡片',
     },
+    pageView: {
+      cards: '卡片',
+      camWall: '攝影機牆',
+    },
+    camWall: {
+      noPrinters: '沒有可顯示的印表機',
+      noSignal: '無訊號',
+      live: '直播',
+      snap: '快照',
+      off: '關閉',
+      summary: '直播 {{live}} 個,快照 {{snap}} 個,共 {{total}} 個',
+      settings: {
+        title: '攝影機牆設定',
+        maxLive: '最大直播數',
+        maxLiveHint: '同時直播的畫面數量。其他畫面以快照重新整理。',
+        snapshotInterval: '快照重新整理間隔(秒)',
+        snapshotIntervalHint: '非直播畫面取得新快照的頻率。',
+      },
+    },
     // Controls
     hideOffline: '隱藏離線',
     nextAvailable: '下一個可用',
@@ -535,11 +554,13 @@ export default {
       hours: '小時',
       timeRemaining: '剩餘 {{time}}',
       active: '乾燥中',
+      targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支援乾燥',
       powerRequired: '連線AMS電源介面卡以啟用乾燥',
       startingDrying: '正在啟動乾燥...',
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
+      rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
     },
     amsBackup: {
       titleOn: 'AMS 備用料盤已開啟。點擊以停用。',
@@ -1845,6 +1866,10 @@ export default {
     checkPrinterFirmware: '檢查印表機韌體',
     includeBetaUpdates: '包含測試版本',
     includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
+    localLogin: {
+      disable: '停用本機使用者名稱/密碼登入',
+      disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',
+    },
     // Queue
     enableRetry: '啟用重試',
     // Home Assistant
@@ -2013,6 +2038,8 @@ export default {
     queueDryingBlockDescription: '阻止列印佇列直到乾燥完成。關閉時,列印優先於乾燥。',
     ambientDryingEnabled: '環境乾燥',
     ambientDryingEnabledDescription: '當空閒印表機的濕度超過閾值時自動乾燥耗材,無需佇列列印。',
+    printDryingEnabled: '列印時繼續乾燥',
+    printDryingEnabledDescription: '允許自動乾燥在支援的硬體(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新韌體)列印過程中繼續執行。為保護料盤,乾燥溫度會自動比閒置時的預設低 5°C。',
     dryingPresets: '乾燥預設',
     dryingPresetsDescription: '每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度,AMS-HT支援較高溫度。',
     dryingFilament: '耗材',
@@ -2499,6 +2526,8 @@ export default {
         defaultGroup: '預設群組',
         defaultGroupDesc: '自動建立使用者時分配的群組。未設定時回退到 Viewers。',
         defaultGroupViewersFallback: 'Viewers(預設)',
+        autologin: '自動登入',
+        autologinDesc: '將未驗證的訪客直接重新導向至此提供者。此旗標僅能由一個提供者持有。',
       },
     },
 
@@ -2648,6 +2677,8 @@ export default {
     signingIn: '登入中...',
     rememberMe: '記住我',
     forgotPassword: '忘記密碼?',
+    autologinFailed: '自動SSO登入失敗。請在下方選擇一個提供者以繼續。',
+    localDisabledNotice: '本機登入已停用。請使用下方的SSO提供者之一。',
     loginSuccess: '登入成功',
     loginFailed: '登入失敗',
     enterCredentials: '請輸入使用者名稱和密碼',

+ 50 - 0
frontend/src/pages/LoginPage.tsx

@@ -165,6 +165,42 @@ export function LoginPage() {
     queryFn: () => api.getOIDCProviders(),
   });
 
+  // #1589: autologin redirect with fallback. When the backend reports an
+  // `autologin_provider_id`, redirect unauthenticated visitors directly to
+  // that provider's authorize URL on mount — unless the URL carries
+  // `?fallback=local` (the documented recovery path that pairs with the
+  // server-side BAMBUDDY_LOCAL_LOGIN env-var bypass). The authorize-URL
+  // fetch is raced against a 5-second timeout; on timeout or fetch error
+  // we skip the redirect and render the normal page, surfacing a banner
+  // so the user understands why autologin didn't kick in.
+  const [autologinFailed, setAutologinFailed] = useState(false);
+  const autologinAttemptedRef = useRef(false);
+  useEffect(() => {
+    if (autologinAttemptedRef.current) return;
+    const fallbackQuery = searchParams.get('fallback');
+    if (fallbackQuery === 'local') return;
+    if (!advancedAuthStatus || !advancedAuthStatus.autologin_provider_id) return;
+    // Don't redirect mid-OIDC-exchange (we're already coming back from the IdP).
+    const hash = window.location.hash;
+    if (hash.startsWith('#oidc_token=') || searchParams.get('oidc_error')) return;
+    autologinAttemptedRef.current = true;
+
+    const providerId = advancedAuthStatus.autologin_provider_id;
+    const timeoutPromise = new Promise<never>((_resolve, reject) =>
+      setTimeout(() => reject(new Error('autologin timeout')), 5000),
+    );
+    Promise.race([api.getOIDCAuthorizeUrl(providerId), timeoutPromise])
+      .then((result) => {
+        window.location.href = (result as { auth_url: string }).auth_url;
+      })
+      .catch(() => {
+        setAutologinFailed(true);
+      });
+  }, [advancedAuthStatus, searchParams]);
+
+  const localLoginEnabled = advancedAuthStatus?.local_login_enabled !== false;
+  const showAutologinBanner = autologinFailed && advancedAuthStatus?.autologin_provider_id != null;
+
   // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
   // Fragments are never sent to the server so the token never appears in access-logs
   // or Referer headers — mirrors the H-4 treatment of the OIDC token.
@@ -667,6 +703,19 @@ export function LoginPage() {
           </p>
         </div>
 
+        {showAutologinBanner && (
+          <div className="mt-6 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
+            {t('login.autologinFailed')}
+          </div>
+        )}
+
+        {!localLoginEnabled && (
+          <div className="mt-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 px-4 py-3 text-sm text-bambu-gray">
+            {t('login.localDisabledNotice')}
+          </div>
+        )}
+
+        {localLoginEnabled && (
         <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
           <div className="space-y-4">
             <div>
@@ -739,6 +788,7 @@ export function LoginPage() {
             </button>
           </div>
         </form>
+        )}
 
         {/* OIDC provider buttons */}
         {oidcProviders && oidcProviders.length > 0 && (

+ 146 - 15
frontend/src/pages/PrintersPage.tsx

@@ -82,6 +82,8 @@ import {
   SlidersHorizontal,
   Stethoscope,
   LineChart as LineChartIcon,
+  LayoutGrid,
+  MonitorPlay,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -94,6 +96,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
+import { CameraWall } from '../components/CameraWall';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
@@ -4541,6 +4544,11 @@ function PrinterCard({
                               <div className="flex items-center gap-2 rounded-lg bg-amber-500/10 px-2 py-1 text-[9px]">
                                 <Flame className="w-3 h-3 text-amber-400 shrink-0" />
                                 <span className="text-amber-400 font-medium">{t('printers.drying.active')}</span>
+                                {ams.dry_filament && ams.dry_target_temp != null && (
+                                  <span className="text-amber-300/70">
+                                    {t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
+                                  </span>
+                                )}
                                 <span className="text-amber-300/70">
                                   {t('printers.drying.timeRemaining', {
                                     time: ams.dry_time >= 60
@@ -5018,6 +5026,11 @@ function PrinterCard({
                             {ams.dry_time > 0 && (
                               <div className="flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg bg-amber-500/10 px-2 py-1 text-[9px]">
                                 <Flame className="w-3 h-3 text-amber-400 shrink-0" />
+                                {ams.dry_filament && ams.dry_target_temp != null && (
+                                  <span className="text-amber-300/70 text-[8px] truncate">
+                                    {t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
+                                  </span>
+                                )}
                                 <span className="text-amber-300/70 text-[8px] truncate">
                                   {ams.dry_time >= 60
                                     ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
@@ -6371,19 +6384,41 @@ function PrinterCard({
                     <span>24h</span>
                   </div>
                 </div>
-                {/* Rotate tray */}
-                <button
-                  type="button"
-                  onClick={() => setDryingRotateTray(enabled => !enabled)}
-                  aria-pressed={dryingRotateTray}
-                  className={`h-8 w-full rounded-lg border px-2 text-sm font-medium transition-colors ${
-                    dryingRotateTray
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
-                  }`}
-                >
-                  {t('printers.drying.rotateTray')}
-                </button>
+                {/* Rotate tray — disabled when any tray in THIS AMS has its
+                    filament threaded out into the feed tube. The whole AMS
+                    rotates as one mechanism (all 4 spools turn together), so a
+                    single loaded slot locks the entire unit. Bambu per-tray
+                    `state`: 9 = empty, 10 = spool present but not loaded
+                    (rotation possible), 11 = loaded into tube (rotation impossible).
+                    Catches both mid-print (active feed) AND idle-with-threaded-
+                    filament — the H2D's post-print state leaves filament in the
+                    tube but tray_now resets to 255, which a tray_now-only check
+                    would silently miss. */}
+                {(() => {
+                  const targetAms = dryingPopoverAmsId !== null
+                    ? amsData.find(a => a.id === dryingPopoverAmsId)
+                    : undefined;
+                  const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
+                    tray => tray.state === 11,
+                  );
+                  const rotateChecked = dryingRotateTray && !trayLoadedInThisAms;
+                  return (
+                    <button
+                      type="button"
+                      onClick={() => setDryingRotateTray(enabled => !enabled)}
+                      aria-pressed={rotateChecked}
+                      disabled={trayLoadedInThisAms}
+                      title={trayLoadedInThisAms ? t('printers.drying.rotateUnavailableReason') : undefined}
+                      className={`h-8 w-full rounded-lg border px-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
+                        rotateChecked
+                          ? 'bg-bambu-green border-bambu-green text-white'
+                          : 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark'
+                      }`}
+                    >
+                      {t('printers.drying.rotateTray')}
+                    </button>
+                  );
+                })()}
               </div>
               <div className="shrink-0 h-px bg-bambu-dark-tertiary" />
               {/* Footer */}
@@ -6391,7 +6426,23 @@ function PrinterCard({
                 <button
                   onClick={() => {
                     if (dryingPopoverAmsId !== null) {
-                      startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament, rotateTray: dryingRotateTray });
+                      // Clamp rotateTray off when any tray in this AMS is loaded into
+                      // the tube — the rotate UI is disabled there, but the state may
+                      // linger as `true` from a previous AMS, or a print may have
+                      // started while the popover was open. Without this clamp the
+                      // Start payload would carry rotate_tray=true and firmware would
+                      // reject with dry_sf_reason=[3] (ConsumableAtAmsOutlet).
+                      const targetAms = amsData.find(a => a.id === dryingPopoverAmsId);
+                      const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
+                        tray => tray.state === 11,
+                      );
+                      startDryingMutation.mutate({
+                        amsId: dryingPopoverAmsId,
+                        temp: dryingTemp,
+                        duration: dryingDuration,
+                        filament: dryingFilament,
+                        rotateTray: dryingRotateTray && !trayLoadedInThisAms,
+                      });
                     }
                   }}
                   disabled={startDryingMutation.isPending}
@@ -7556,6 +7607,20 @@ export function PrintersPage() {
     const saved = localStorage.getItem('printerCardSize');
     return saved ? parseInt(saved, 10) : 2; // Default to medium
   });
+  // Page view: 'cards' = printer cards (default), 'camwall' = grid of live camera tiles
+  const [pageView, setPageView] = useState<'cards' | 'camwall'>(() => {
+    return localStorage.getItem('printerPageView') === 'camwall' ? 'camwall' : 'cards';
+  });
+  // Cam-wall settings — per-user, no backend write (a Pi 4 install caps the
+  // live count lower than a NUC; default 4 is the documented Pi 4 ceiling).
+  const [camWallMaxLive, setCamWallMaxLive] = useState<number>(() => {
+    const saved = parseInt(localStorage.getItem('camWallMaxLive') || '', 10);
+    return Number.isFinite(saved) && saved > 0 ? saved : 4;
+  });
+  const [camWallSnapshotSec, setCamWallSnapshotSec] = useState<number>(() => {
+    const saved = parseInt(localStorage.getItem('camWallSnapshotSec') || '', 10);
+    return Number.isFinite(saved) && saved > 0 ? saved : 8;
+  });
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const [compactDrilldownPrinterId, setCompactDrilldownPrinterId] = useState<number | null>(null);
@@ -8285,8 +8350,43 @@ export function PrintersPage() {
         </button>
       </div>
 
-      {/* Card size selector */}
+      {/* Page view toggle: Cards / Cam Wall */}
       <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${inMenu ? 'w-full' : ''}`}>
+        <button
+          type="button"
+          onClick={() => {
+            setPageView('cards');
+            localStorage.setItem('printerPageView', 'cards');
+          }}
+          className={`flex h-full items-center gap-1 rounded-l-lg px-2 text-xs font-medium transition-colors ${inMenu ? 'flex-1 justify-center' : ''} ${
+            pageView === 'cards' ? 'bg-bambu-green text-white' : 'text-white hover:bg-bambu-dark-tertiary'
+          }`}
+          title={t('printers.pageView.cards')}
+          aria-pressed={pageView === 'cards'}
+        >
+          <LayoutGrid className="w-3.5 h-3.5" />
+          {inMenu && <span>{t('printers.pageView.cards')}</span>}
+        </button>
+        <button
+          type="button"
+          onClick={() => {
+            setPageView('camwall');
+            localStorage.setItem('printerPageView', 'camwall');
+          }}
+          className={`flex h-full items-center gap-1 rounded-r-lg px-2 text-xs font-medium transition-colors ${inMenu ? 'flex-1 justify-center' : ''} ${
+            pageView === 'camwall' ? 'bg-bambu-green text-white' : 'text-white hover:bg-bambu-dark-tertiary'
+          }`}
+          title={t('printers.pageView.camWall')}
+          aria-pressed={pageView === 'camwall'}
+          disabled={!hasPermission('camera:view')}
+        >
+          <MonitorPlay className="w-3.5 h-3.5" />
+          {inMenu && <span>{t('printers.pageView.camWall')}</span>}
+        </button>
+      </div>
+
+      {/* Card size selector */}
+      <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${pageView === 'camwall' ? 'opacity-40 pointer-events-none' : ''} ${inMenu ? 'w-full' : ''}`}>
         {cardSizeLabels.map((label, index) => {
           const size = index + 1;
           const isSelected = cardSize === size;
@@ -8490,6 +8590,37 @@ export function PrintersPage() {
             <p className="text-bambu-gray">{t('printers.noSearchResults')}</p>
           </CardContent>
         </Card>
+      ) : pageView === 'camwall' ? (
+        <CameraWall
+          printers={sortedPrinters}
+          maxLive={camWallMaxLive}
+          snapshotIntervalSec={camWallSnapshotSec}
+          onTileClick={(id, name) => {
+            const cameraMode = settings?.camera_view_mode || 'window';
+            if (cameraMode === 'embedded') {
+              setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }));
+            } else {
+              const saved = localStorage.getItem('cameraWindowState');
+              const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
+              const features = [
+                `width=${state.width}`,
+                `height=${state.height}`,
+                state.left !== undefined ? `left=${state.left}` : '',
+                state.top !== undefined ? `top=${state.top}` : '',
+                'menubar=no,toolbar=no,location=no,status=no',
+              ].filter(Boolean).join(',');
+              window.open(`/camera/${id}`, `camera-${id}`, features);
+            }
+          }}
+          onChangeMaxLive={(next) => {
+            setCamWallMaxLive(next);
+            localStorage.setItem('camWallMaxLive', String(next));
+          }}
+          onChangeSnapshotIntervalSec={(next) => {
+            setCamWallSnapshotSec(next);
+            localStorage.setItem('camWallSnapshotSec', String(next));
+          }}
+        />
       ) : groupedPrinters ? (
         /* Grouped view (location, status, or model) */
         <div className="space-y-6">

+ 41 - 2
frontend/src/pages/SettingsPage.tsx

@@ -554,7 +554,7 @@ export function SettingsPage() {
   });
 
   // Advanced auth status for user creation
-  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({
+  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false, local_login_enabled: true, autologin_provider_id: null } } = useQuery({
     queryKey: ['advancedAuthStatus'],
     queryFn: () => api.getAdvancedAuthStatus(),
   });
@@ -934,6 +934,7 @@ export function SettingsPage() {
       settings.check_updates !== localSettings.check_updates ||
       (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
+      (settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
       settings.notification_language !== localSettings.notification_language ||
       (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
@@ -946,6 +947,7 @@ export function SettingsPage() {
       (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
+      (settings.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
       (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       (settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
       settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
@@ -1026,6 +1028,7 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         include_beta_updates: localSettings.include_beta_updates,
+        local_login_enabled: localSettings.local_login_enabled,
         notification_language: localSettings.notification_language,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
         ams_humidity_good: localSettings.ams_humidity_good,
@@ -1038,6 +1041,7 @@ export function SettingsPage() {
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_block: localSettings.queue_drying_block,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
+        print_drying_enabled: localSettings.print_drying_enabled,
         drying_presets: localSettings.drying_presets,
         ams_humidity_thresholds: localSettings.ams_humidity_thresholds,
         per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
@@ -4583,6 +4587,25 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <label className="block text-sm text-white">
+                    {t('settings.printDryingEnabled')}
+                  </label>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.printDryingEnabledDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.print_drying_enabled ?? false}
+                    onChange={(e) => updateSetting('print_drying_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
               {/* Drying Presets Table */}
               <div className="space-y-2">
                 <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
@@ -5651,7 +5674,23 @@ export function SettingsPage() {
           )}
 
           {usersSubTab === 'oidc' && isAdmin && (
-            <div className="max-w-3xl">
+            <div className="max-w-3xl space-y-4">
+              <Card>
+                <CardContent className="space-y-3 p-4">
+                  <label className="flex items-start gap-3 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.local_login_enabled === false}
+                      onChange={(e) => updateSetting('local_login_enabled', !e.target.checked)}
+                      className="mt-1 h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
+                    />
+                    <div>
+                      <p className="text-sm font-medium text-white">{t('settings.localLogin.disable')}</p>
+                      <p className="text-xs text-bambu-gray mt-0.5">{t('settings.localLogin.disableHint')}</p>
+                    </div>
+                  </label>
+                </CardContent>
+              </Card>
               <OIDCProviderSettings />
             </div>
           )}

Некоторые файлы не были показаны из-за большого количества измененных файлов