Browse Source

feat(wled): add preset-based WLED integration for printer status visualization (#3124)

d3nn3s08 3 days ago
parent
commit
0bf86c2947
40 changed files with 3329 additions and 25 deletions
  1. 1 0
      .gitignore
  2. 73 0
      backend/app/api/routes/printers.py
  3. 7 0
      backend/app/api/routes/settings.py
  4. 2 0
      backend/app/core/database.py
  5. 28 18
      backend/app/main.py
  6. 3 1
      backend/app/models/printer.py
  7. 84 1
      backend/app/schemas/printer.py
  8. 18 0
      backend/app/services/hms_errors.py
  9. 6 0
      backend/app/services/printer_manager.py
  10. 396 0
      backend/app/services/wled.py
  11. 61 0
      backend/tests/integration/test_printer_scope_1727.py
  12. 187 0
      backend/tests/integration/test_printers_api.py
  13. 14 0
      backend/tests/integration/test_settings_api.py
  14. 860 0
      backend/tests/unit/services/test_wled.py
  15. 2 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  16. 43 1
      backend/tests/unit/test_printer_schema.py
  17. 30 0
      backend/tests/unit/test_wled_config_migration.py
  18. 48 0
      backend/tests/unit/test_wled_startup.py
  19. 767 0
      frontend/src/__tests__/components/WLEDSettings.test.tsx
  20. 73 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  21. 47 0
      frontend/src/api/client.ts
  22. 391 0
      frontend/src/components/WLEDSettings.tsx
  23. 9 0
      frontend/src/i18n/locales/de.ts
  24. 9 0
      frontend/src/i18n/locales/en.ts
  25. 9 0
      frontend/src/i18n/locales/es.ts
  26. 9 0
      frontend/src/i18n/locales/fr.ts
  27. 9 0
      frontend/src/i18n/locales/it.ts
  28. 9 0
      frontend/src/i18n/locales/ja.ts
  29. 9 0
      frontend/src/i18n/locales/ko.ts
  30. 9 0
      frontend/src/i18n/locales/nl.ts
  31. 9 0
      frontend/src/i18n/locales/pl.ts
  32. 9 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 9 0
      frontend/src/i18n/locales/ru.ts
  34. 42 0
      frontend/src/i18n/locales/sv.ts
  35. 9 0
      frontend/src/i18n/locales/tr.ts
  36. 9 0
      frontend/src/i18n/locales/uk.ts
  37. 9 0
      frontend/src/i18n/locales/zh-CN.ts
  38. 9 0
      frontend/src/i18n/locales/zh-TW.ts
  39. 1 0
      frontend/src/lib/settingsSearch.ts
  40. 10 4
      frontend/src/pages/SettingsPage.tsx

+ 1 - 0
.gitignore

@@ -52,6 +52,7 @@ firmware/
 
 # Screenshots (development - root folder only)
 /screenshots/
+output/playwright/
 
 # Logs
 *.log

+ 73 - 0
backend/app/api/routes/printers.py

@@ -5,6 +5,7 @@ import secrets
 import zipfile
 from pathlib import Path
 
+import httpx
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse, Response
 from sqlalchemy import delete, func, select
@@ -50,6 +51,10 @@ from backend.app.schemas.printer import (
     PrinterStatus,
     PrinterUpdate,
     PrintOptionsResponse,
+    WLEDConnectionInfo,
+    WLEDPreset,
+    WLEDPresetListRequest,
+    WLEDPresetTestRequest,
     hms_error_responses,
 )
 from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
@@ -102,6 +107,7 @@ from backend.app.services.printer_media import (
 )
 from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
 from backend.app.services.slot_nozzle import reports_two_nozzles, resolve_slot_nozzle
+from backend.app.services.wled import WLEDResponseError, wled_manager
 from backend.app.utils.filament_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -227,6 +233,8 @@ async def create_printer(
     if await location_grantees(db, [printer.location]):
         await ws_manager.refresh_printer_scopes()
 
+    wled_manager.configure_printer(printer.id, printer.wled_config)
+
     # Connect to the printer
     if printer.is_active:
         await printer_manager.connect_printer(printer)
@@ -234,6 +242,57 @@ async def create_printer(
     return printer
 
 
+@router.post("/{printer_id}/wled/presets", response_model=list[WLEDPreset])
+async def list_wled_presets(
+    printer_id: int,
+    request: WLEDPresetListRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Read existing presets from WLED without changing its configuration."""
+    if not await db.scalar(select(Printer.id).where(Printer.id == printer_id)):
+        raise HTTPException(404, "Printer not found")
+    try:
+        return await wled_manager.list_presets(request.base_url)
+    except httpx.HTTPStatusError as exc:
+        raise HTTPException(502, f"WLED returned HTTP {exc.response.status_code}") from exc
+    except (httpx.HTTPError, WLEDResponseError) as exc:
+        raise HTTPException(502, "Could not load WLED presets") from exc
+
+
+@router.post("/{printer_id}/wled/test-connection", response_model=WLEDConnectionInfo)
+async def test_wled_connection(
+    printer_id: int,
+    request: WLEDPresetListRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Check WLED connectivity and return its optional identity."""
+    if not await db.scalar(select(Printer.id).where(Printer.id == printer_id)):
+        raise HTTPException(404, "Printer not found")
+    try:
+        return await wled_manager.get_info(request.base_url)
+    except httpx.HTTPStatusError as exc:
+        raise HTTPException(502, f"WLED returned HTTP {exc.response.status_code}") from exc
+    except (httpx.HTTPError, WLEDResponseError) as exc:
+        raise HTTPException(502, "Could not connect to WLED") from exc
+
+
+@router.post("/{printer_id}/wled/test-preset")
+async def test_wled_preset(
+    printer_id: int,
+    request: WLEDPresetTestRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Activate one validated preset without changing stored configuration."""
+    if not await db.scalar(select(Printer.id).where(Printer.id == printer_id)):
+        raise HTTPException(404, "Printer not found")
+    if not await wled_manager.test_preset(printer_id, request.base_url, request.preset_id):
+        raise HTTPException(502, "Could not activate WLED preset")
+    return {"success": True}
+
+
 @router.get("/usb-cameras")
 async def list_usb_cameras(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
@@ -469,11 +528,23 @@ async def update_printer(
         except Exception as e:
             logger.error("Failed to sync virtual printers after printer IP change: %s", e)
 
+    if "wled_config" in update_data:
+        wled_manager.configure_printer(printer_id, printer.wled_config)
+        state = printer_manager.get_status(printer_id)
+        if state and printer.is_active:
+            wled_manager.handle_status(
+                printer_id,
+                state,
+                awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
+            )
+
     # Reconnect if connection settings changed
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
         printer_manager.disconnect_printer(printer_id)
         if printer.is_active:
             await printer_manager.connect_printer(printer)
+        elif "is_active" in update_data:
+            wled_manager.handle_offline(printer_id)
 
     return printer
 
@@ -540,6 +611,7 @@ async def delete_printer(
         notify = await delete_provider(db, provider) or notify
 
     printer_manager.disconnect_printer(printer_id)
+    wled_manager.remove_printer(printer_id)
 
     # Locked from the archive sweep to the commit (PostgreSQL; SQLite's single
     # writer serialises this anyway). A print start adopting or writing an
@@ -1038,6 +1110,7 @@ async def disconnect_printer(
         raise HTTPException(404, "Printer not found")
 
     printer_manager.disconnect_printer(printer_id)
+    wled_manager.handle_offline(printer_id)
     return {"connected": False}
 
 

+ 7 - 0
backend/app/api/routes/settings.py

@@ -506,6 +506,10 @@ async def update_settings(
 
     if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
         camera_light.invalidate_settings()
+    if "require_plate_clear" in update_data:
+        from backend.app.services.wled import wled_manager
+
+        wled_manager.require_plate_clear = update_data["require_plate_clear"]
 
     if price_source_changed:
         # Read a newly chosen price sensor now, so the saved settings show its
@@ -599,6 +603,9 @@ async def reset_settings(
 
     await db.commit()
     camera_light.invalidate_settings()
+    from backend.app.services.wled import wled_manager
+
+    wled_manager.require_plate_clear = False
 
     return DEFAULT_SETTINGS
 

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

@@ -5872,6 +5872,8 @@ async def _run_migrations(conn):
         await conn.execute(
             text("UPDATE printers SET camera_light_auto = :off WHERE camera_light_auto IS NULL"), {"off": False}
         )
+    # Migration: optional per-printer WLED preset configuration (#1528).
+    await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN wled_config JSON")
 
     # Migration: printer-scoped groups (#1727). Defaults off, so no existing
     # group narrows anyone's printers on upgrade; the group_printers table

+ 28 - 18
backend/app/main.py

@@ -23,6 +23,7 @@ from fastapi.staticfiles import StaticFiles
 from sqlalchemy import delete, func, inspect as sa_inspect, or_, select, text, update
 from sqlalchemy.engine import CursorResult
 from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm.exc import StaleDataError
 
 from backend.app.api.routes import (
@@ -132,6 +133,7 @@ from backend.app.services.camera_light import camera_light
 from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
+from backend.app.services.hms_errors import hms_fault_counts as _hms_fault_counts
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
@@ -177,6 +179,7 @@ from backend.app.services.spoolman_tracking import (
     store_print_data as _store_spoolman_print_data,
 )
 from backend.app.services.telegram_reactions import telegram_reaction_poller
+from backend.app.services.wled import wled_manager
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_types import printer_filament_type
@@ -2020,24 +2023,6 @@ def _hms_notify_key(error) -> str:
     return f"{error.attr:08X}:{error.code}"
 
 
-def _hms_fault_counts(error) -> bool:
-    """Whether a fault counts as a problem: the same rule the frontend's
-    ``filterKnownHMSErrors`` applies to the printer card, badge and camera wall.
-
-    It counts when Bambu publishes text for it or it offers action buttons, and
-    its level is a real one. An ``hms[]`` fault at level 3 (notification) with
-    no actions does not count: those are things like "the top cover is open" or
-    "the chamber is hot, fan speed increased", which a printer can hold through
-    a whole print. A ``print_error`` at the same level (0xCxxx) still counts, as
-    it always has; those are prompts such as "unable to start drying" (#2728).
-    """
-    if error.severity < 1:
-        return False
-    has_actions = bool(getattr(error, "actions", None))
-    is_hms_notice = len(getattr(error, "full_code", "") or "") == 16 and error.severity == 3
-    return has_actions or (bool(getattr(error, "description", None)) and not is_hms_notice)
-
-
 def _hms_errors_to_notify(errors: list, new_error_codes: set[str]) -> list:
     """The new faults that count (see ``_hms_fault_counts``).
 
@@ -2148,6 +2133,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         pause_eta.observe(printer_id, state)
     except Exception:
         logging.getLogger(__name__).exception("Pause heads-up check failed for printer %s", printer_id)
+    wled_manager.handle_status(
+        printer_id,
+        state,
+        awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
+    )
     # Connected-edge reconciliation (#1542 follow-up). When the printer
     # transitions disconnected → connected — which covers both Bambuddy
     # startup (no prior connection) and a mid-session MQTT reconnect — fire
@@ -11948,6 +11938,19 @@ def stop_auth_cleanup() -> None:
         logging.getLogger(__name__).info("Auth periodic cleanup stopped")
 
 
+async def initialize_wled_printers(db: AsyncSession) -> None:
+    """Seed WLED settings and apply offline presets before MQTT connections start."""
+    from backend.app.api.routes.settings import get_setting, setting_is_true
+    from backend.app.models.printer import Printer
+
+    wled_manager.require_plate_clear = setting_is_true(await get_setting(db, "require_plate_clear"))
+    result = await db.execute(select(Printer.id, Printer.wled_config, Printer.is_active))
+    for printer_id, config, is_active in result.all():
+        wled_manager.configure_printer(printer_id, config)
+        if not is_active and config:
+            wled_manager.handle_offline(printer_id)
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -12062,6 +12065,11 @@ async def lifespan(app: FastAPI):
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
     await printer_manager.load_awaiting_plate_clear_from_db()
 
+    # WLED is entirely optional. Seed its in-memory per-printer configuration
+    # before MQTT connections begin emitting status callbacks.
+    async with async_session() as db:
+        await initialize_wled_printers(db)
+
     # Layer change callback for external camera timelapse
     async def on_layer_change(printer_id: int, layer_num: int):
         """Capture timelapse frame on layer change + first layer notification."""
@@ -12512,6 +12520,8 @@ async def lifespan(app: FastAPI):
 
     await mqtt_relay.disconnect(timeout=2)
 
+    await wled_manager.shutdown()
+
     # Drop the shared Bambu Cloud HTTP client we registered at startup.
     set_shared_http_client(None)
     set_shared_makerworld_http_client(None)

+ 3 - 1
backend/app/models/printer.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, String, false, func
+from sqlalchemy import JSON, Boolean, DateTime, Float, String, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -54,6 +54,8 @@ class Printer(Base):
     # Queue: True after a print finishes/fails, until user acknowledges the plate is cleared.
     # Persisted so the gate survives crashes and power cycles (issue #961).
     awaiting_plate_clear: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional per-printer WLED preset mapping. WLED owns all visual effects.
+    wled_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 

+ 84 - 1
backend/app/schemas/printer.py

@@ -1,6 +1,7 @@
 from datetime import datetime
+from urllib.parse import urlparse
 
-from pydantic import BaseModel, Field, field_validator, model_validator
+from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
 
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.printer_location import normalize_location_name
@@ -26,6 +27,83 @@ def _mask_url_login(url: str | None) -> str | None:
     return f"{url[:start]}{shown}@{host}{url[end:]}"
 
 
+class WLEDPresets(BaseModel):
+    idle: int | None = Field(default=None, ge=1, le=250)
+    prepare: int | None = Field(default=None, ge=1, le=250)
+    printing: int | None = Field(default=None, ge=1, le=250)
+    paused: int | None = Field(default=None, ge=1, le=250)
+    finished: int | None = Field(default=None, ge=1, le=250)
+    error: int | None = Field(default=None, ge=1, le=250)
+    queue_waiting: int | None = Field(default=None, ge=1, le=250)
+    filament_problem: int | None = Field(default=None, ge=1, le=250)
+    hms_error: int | None = Field(default=None, ge=1, le=250)
+    offline: int | None = Field(default=None, ge=1, le=250)
+
+
+class WLEDStoredConfig(BaseModel):
+    enabled: bool = False
+    base_url: str | None = None
+    presets: WLEDPresets = Field(default_factory=WLEDPresets)
+    finished_timeout_seconds: int | None = Field(default=None, ge=0, le=86400)
+
+    @field_serializer("base_url")
+    def _mask_stored_url_login(self, value: str | None) -> str | None:
+        return _mask_url_login(value)
+
+
+class WLEDConfig(WLEDStoredConfig):
+    base_url: str | None = Field(default=None, max_length=500)
+
+    @field_validator("base_url")
+    @classmethod
+    def _validate_base_url(cls, value: str | None) -> str | None:
+        if value is None:
+            return None
+        normalized = value.strip().rstrip("/")
+        if not normalized:
+            return None
+
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+        assert_safe_lan_service_url(normalized, label="WLED URL")
+        parsed = urlparse(normalized)
+        if parsed.username is not None or parsed.password is not None:
+            raise ValueError("WLED URL must not include credentials")
+        return normalized
+
+    @model_validator(mode="after")
+    def _require_base_url_when_enabled(self):
+        if self.enabled and not self.base_url:
+            raise ValueError("base_url is required when WLED is enabled")
+        return self
+
+
+class WLEDPreset(BaseModel):
+    id: int
+    name: str
+
+
+class WLEDPresetListRequest(BaseModel):
+    base_url: str = Field(max_length=500)
+
+    @field_validator("base_url")
+    @classmethod
+    def _validate_base_url(cls, value: str) -> str:
+        normalized = WLEDConfig._validate_base_url(value)
+        if not normalized:
+            raise ValueError("base_url is required")
+        return normalized
+
+
+class WLEDPresetTestRequest(WLEDPresetListRequest):
+    preset_id: int = Field(ge=1, le=250)
+
+
+class WLEDConnectionInfo(BaseModel):
+    name: str | None = None
+    version: str | None = None
+
+
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
@@ -68,6 +146,9 @@ class PrinterCreate(PrinterBase):
     # PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     access_code: str = Field(..., min_length=1, max_length=20)
+    # Input shape validates the URL; PrinterResponse serialises stored rows
+    # with WLEDStoredConfig so one bad stored URL cannot break GET /printers.
+    wled_config: WLEDConfig | None = None
 
     # Input only, not on PrinterBase: SQLite never enforced the column width,
     # so a stored location may be longer, and the response must still read it.
@@ -108,9 +189,11 @@ class PrinterUpdate(IgnoresNullForRequiredColumns):
     camera_light_auto: bool | None = None  # #1655
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
+    wled_config: WLEDConfig | None = None
 
 
 class PrinterResponse(PrinterBase):
+    wled_config: WLEDStoredConfig | None = None
     id: int
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT

+ 18 - 0
backend/app/services/hms_errors.py

@@ -90,3 +90,21 @@ def alert_level_from_print_error(error: int) -> int:
     way. 0 for anything else, which Bambu defines as an invalid level.
     """
     return {0x4: 1, 0x8: 2, 0xC: 3}.get((error >> 12) & 0xF, 0)
+
+
+def hms_fault_counts(error) -> bool:
+    """Whether a fault counts as a problem: the same rule the frontend's
+    ``filterKnownHMSErrors`` applies to the printer card, badge and camera wall.
+
+    It counts when Bambu publishes text for it or it offers action buttons, and
+    its level is a real one. An ``hms[]`` fault at level 3 (notification) with
+    no actions does not count: those are things like "the top cover is open" or
+    "the chamber is hot, fan speed increased", which a printer can hold through
+    a whole print. A ``print_error`` at the same level (0xCxxx) still counts, as
+    it always has; those are prompts such as "unable to start drying" (#2728).
+    """
+    if error.severity < 1:
+        return False
+    has_actions = bool(getattr(error, "actions", None))
+    is_hms_notice = len(getattr(error, "full_code", "") or "") == 16 and error.severity == 3
+    return has_actions or (bool(getattr(error, "description", None)) and not is_hms_notice)

+ 6 - 0
backend/app/services/printer_manager.py

@@ -543,6 +543,12 @@ class PrinterManager:
         except Exception as e:
             logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
 
+        state = self.get_status(printer_id)
+        if state:
+            from backend.app.services.wled import wled_manager
+
+            wled_manager.handle_status(printer_id, state, awaiting_plate_clear=awaiting)
+
         # Only the rising edge is worth a notification — "the bed is now free"
         # is not an action item, and the queue clears the gate by itself.
         if not awaiting:

+ 396 - 0
backend/app/services/wled.py

@@ -0,0 +1,396 @@
+"""Optional per-printer WLED preset integration."""
+
+import asyncio
+import logging
+import time
+from dataclasses import dataclass
+
+import httpx
+
+from backend.app.core.tasks import spawn_background_task
+from backend.app.schemas.printer import WLEDConfig
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.hms_errors import hms_fault_counts
+
+logger = logging.getLogger(__name__)
+
+# While WLED does not answer, the same preset is retried at most this often
+# instead of on every printer status update.
+WLED_RETRY_SECONDS = 30.0
+
+
+class WLEDResponseError(ValueError):
+    """WLED returned JSON that does not match the expected API shape."""
+
+
+def effective_wled_status(
+    state: PrinterState, *, awaiting_plate_clear: bool = False, require_plate_clear: bool = False
+) -> str | None:
+    """Map Bambuddy's existing printer state to a WLED mapping key."""
+    statuses = _applicable_wled_statuses(
+        state, awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=require_plate_clear
+    )
+    return statuses[0] if statuses else None
+
+
+def _applicable_wled_statuses(
+    state: PrinterState, *, awaiting_plate_clear: bool = False, require_plate_clear: bool = False
+) -> list[str]:
+    """Return applicable states in priority order for mapped-preset fallback."""
+    if not state.connected:
+        return ["offline"]
+
+    printer_state = (state.state or "").upper()
+    statuses = []
+    if printer_state == "PAUSE" and state.stg_cur == 6:
+        statuses.append("filament_problem")
+    if any(hms_fault_counts(error) for error in state.hms_errors):
+        statuses.append("hms_error")
+    if require_plate_clear and awaiting_plate_clear and printer_state in {"IDLE", "FINISH", "FAILED"}:
+        statuses.append("queue_waiting")
+    if printer_state == "FAILED":
+        statuses.append("error")
+    elif printer_state == "PAUSE":
+        statuses.append("paused")
+    elif printer_state == "FINISH":
+        statuses.append("finished")
+    elif printer_state in {"RUNNING", "PRINTING"}:
+        statuses.append("printing")
+    elif printer_state in {"PREPARE", "SLICING"}:
+        statuses.append("prepare")
+    elif printer_state == "IDLE":
+        statuses.append("idle")
+    return statuses
+
+
+@dataclass
+class _WLEDRuntime:
+    status: str | None = None
+    preset_id: int | None = None
+    target: tuple[str | None, int | None] | None = None
+    generation: int = 0
+    confirmed_generation: int | None = None
+    finished_timeout_expired: bool = False
+    # Monotonic time before which a failed send is not retried, and whether
+    # the last automatic send failed (so an outage logs once, not per retry).
+    retry_after: float = 0.0
+    unreachable: bool = False
+    send_task: asyncio.Task | None = None
+    finished_task: asyncio.Task | None = None
+
+
+class WLEDManager:
+    """Own WLED HTTP calls, per-printer deduplication, and finished timers."""
+
+    def __init__(self, client: httpx.AsyncClient | None = None):
+        self._client = client
+        self._owns_client = client is None
+        self.require_plate_clear = False
+        self.retry_seconds = WLED_RETRY_SECONDS
+        self._configs: dict[int, WLEDConfig] = {}
+        self._runtime: dict[int, _WLEDRuntime] = {}
+
+    @property
+    def client(self) -> httpx.AsyncClient:
+        if self._client is None:
+            self._client = httpx.AsyncClient(
+                timeout=httpx.Timeout(3.0, connect=2.0),
+                follow_redirects=False,
+            )
+        return self._client
+
+    def configure_printer(self, printer_id: int, config: dict | WLEDConfig | None) -> None:
+        self._cancel_runtime(printer_id, remove=True)
+        try:
+            self._configs[printer_id] = WLEDConfig.model_validate(config or {})
+        except ValueError:
+            self._configs[printer_id] = WLEDConfig()
+            logger.warning("Ignoring invalid WLED configuration for printer %d", printer_id)
+
+    def remove_printer(self, printer_id: int) -> None:
+        self._configs.pop(printer_id, None)
+        self._cancel_runtime(printer_id, remove=True)
+
+    def handle_status(
+        self,
+        printer_id: int,
+        state: PrinterState,
+        *,
+        awaiting_plate_clear: bool = False,
+    ) -> None:
+        """Schedule any needed WLED work without delaying printer handling."""
+        config = self._configs.get(printer_id)
+        if not config or not config.enabled or not config.base_url:
+            self._cancel_runtime(printer_id)
+            return
+
+        status = next(
+            (
+                candidate
+                for candidate in _applicable_wled_statuses(
+                    state, awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=self.require_plate_clear
+                )
+                if getattr(config.presets, candidate) is not None
+            ),
+            None,
+        )
+        preset_id = getattr(config.presets, status, None) if status else None
+        if state.connected and (state.state or "").upper() in {"PREPARE", "SLICING", "RUNNING", "PRINTING"}:
+            self._runtime.setdefault(printer_id, _WLEDRuntime()).finished_timeout_expired = False
+        self._apply_status(printer_id, config, status, preset_id)
+
+    def handle_offline(self, printer_id: int) -> None:
+        """Apply the offline mapping for an explicit printer lifecycle transition."""
+        config = self._configs.get(printer_id)
+        if not config or not config.enabled or not config.base_url:
+            return
+        preset_id = config.presets.offline
+        self._apply_status(printer_id, config, "offline" if preset_id is not None else None, preset_id)
+
+    def _apply_status(self, printer_id: int, config: WLEDConfig, status: str | None, preset_id: int | None) -> None:
+        base_url = config.base_url
+        if not base_url:
+            return
+        idle_preset_id = config.presets.idle
+        runtime = self._runtime.setdefault(printer_id, _WLEDRuntime())
+        expired_finished = runtime.finished_timeout_expired and status == "finished" and idle_preset_id is not None
+        target = (status, preset_id)
+        if runtime.target == target:
+            if expired_finished:
+                if runtime.finished_task is not None and not runtime.finished_task.done():
+                    return
+            elif (
+                preset_id is None
+                or runtime.confirmed_generation == runtime.generation
+                or (runtime.send_task is not None and not runtime.send_task.done())
+            ):
+                return
+            if time.monotonic() < runtime.retry_after:
+                return
+        else:
+            runtime.generation += 1
+            self._cancel_task(runtime.finished_task)
+            runtime.finished_task = None
+            self._cancel_task(runtime.send_task)
+            runtime.target = target
+
+        generation = runtime.generation
+        if expired_finished and idle_preset_id is not None:
+            if runtime.status == "idle" and runtime.preset_id == idle_preset_id:
+                return
+            runtime.finished_task = spawn_background_task(
+                self._send_finished_idle(printer_id, generation, base_url, idle_preset_id),
+                name=f"wled-finished-idle-{printer_id}",
+            )
+            return
+        if preset_id is not None:
+            runtime.send_task = spawn_background_task(
+                self._send_status(printer_id, base_url, status, preset_id, generation),
+                name=f"wled-preset-{printer_id}",
+            )
+
+        if (
+            status == "finished"
+            and config.finished_timeout_seconds
+            and idle_preset_id is not None
+            and runtime.finished_task is None
+        ):
+            runtime.finished_task = spawn_background_task(
+                self._finish_timeout(
+                    printer_id,
+                    generation,
+                    base_url,
+                    idle_preset_id,
+                    config.finished_timeout_seconds,
+                ),
+                name=f"wled-finished-timeout-{printer_id}",
+            )
+
+    async def _send_status(
+        self, printer_id: int, base_url: str, status: str | None, preset_id: int, generation: int
+    ) -> None:
+        ok = await self._send_automatic(printer_id, base_url, preset_id, status)
+        if ok:
+            runtime = self._runtime.get(printer_id)
+            if (
+                runtime
+                and runtime.generation == generation
+                and runtime.target == (status, preset_id)
+                and runtime.send_task is asyncio.current_task()
+            ):
+                runtime.status = status
+                runtime.preset_id = preset_id
+                runtime.confirmed_generation = generation
+
+    async def _send_finished_idle(self, printer_id: int, generation: int, base_url: str, idle_preset_id: int) -> None:
+        if await self._send_automatic(printer_id, base_url, idle_preset_id, "idle"):
+            runtime = self._runtime.get(printer_id)
+            if (
+                runtime
+                and runtime.generation == generation
+                and runtime.finished_timeout_expired
+                and runtime.target
+                and runtime.target[0] == "finished"
+            ):
+                runtime.status = "idle"
+                runtime.preset_id = idle_preset_id
+                runtime.confirmed_generation = generation
+
+    async def _send_automatic(self, printer_id: int, base_url: str, preset_id: int, status: str | None) -> bool:
+        """Send a mapped preset; on failure hold retries back and log once per outage."""
+        runtime = self._runtime.get(printer_id)
+        quiet = runtime is not None and runtime.unreachable
+        ok = await self.send_preset(printer_id, base_url, preset_id, status=status, quiet=quiet)
+        if runtime is None or self._runtime.get(printer_id) is not runtime:
+            return ok
+        if ok:
+            runtime.retry_after = 0.0
+            if runtime.unreachable:
+                runtime.unreachable = False
+                logger.info("[WLED] Printer %d: WLED answers again", printer_id)
+        else:
+            runtime.retry_after = time.monotonic() + self.retry_seconds
+            if not runtime.unreachable:
+                runtime.unreachable = True
+                logger.info(
+                    "[WLED] Printer %d: retrying every %ds until WLED answers", printer_id, int(self.retry_seconds)
+                )
+        return ok
+
+    async def test_preset(self, printer_id: int, base_url: str, preset_id: int) -> bool:
+        """Test a preset while forcing the next status update to restore automatic control."""
+        self._invalidate_automatic(printer_id)
+        try:
+            return await self.send_preset(printer_id, base_url, preset_id)
+        finally:
+            self._invalidate_automatic(printer_id)
+
+    def _invalidate_automatic(self, printer_id: int) -> None:
+        runtime = self._runtime.get(printer_id)
+        if runtime:
+            self._cancel_task(runtime.send_task)
+            runtime.send_task = None
+            runtime.status = None
+            runtime.preset_id = None
+            runtime.confirmed_generation = None
+
+    async def send_preset(
+        self, printer_id: int, base_url: str, preset_id: int, *, status: str | None = None, quiet: bool = False
+    ) -> bool:
+        """Activate a preset. Failures are isolated from all printer work.
+
+        ``quiet`` logs a failure at debug level: a retry during an outage that
+        was already reported.
+        """
+        log_failure = logger.debug if quiet else logger.warning
+        try:
+            response = await self.client.post(f"{base_url}/json/state", json={"ps": preset_id})
+            response.raise_for_status()
+            if not isinstance(response.json(), dict):
+                raise WLEDResponseError("state response must be a JSON object")
+            logger.info("[WLED] Printer %d state=%s -> preset=%d", printer_id, status or "manual", preset_id)
+            return True
+        except asyncio.CancelledError:
+            raise
+        except httpx.HTTPStatusError as exc:
+            log_failure(
+                "[WLED] Printer %d state=%s -> preset=%d failed (HTTP %d)",
+                printer_id,
+                status or "manual",
+                preset_id,
+                exc.response.status_code,
+            )
+        except (httpx.HTTPError, ValueError):
+            log_failure("[WLED] Printer %d state=%s -> preset=%d failed", printer_id, status or "manual", preset_id)
+        return False
+
+    async def list_presets(self, base_url: str) -> list[dict[str, int | str]]:
+        """Return WLED preset names and IDs without modifying WLED state."""
+        response = await self.client.get(f"{base_url}/presets.json")
+        response.raise_for_status()
+        try:
+            payload = response.json()
+        except ValueError as exc:
+            raise WLEDResponseError("presets response is not valid JSON") from exc
+        if not isinstance(payload, dict):
+            raise WLEDResponseError("presets response must be a JSON object")
+
+        presets = []
+        for raw_id, value in payload.items():
+            try:
+                preset_id = int(raw_id)
+            except (TypeError, ValueError):
+                continue
+            if not 1 <= preset_id <= 250 or not isinstance(value, dict):
+                continue
+            name = value.get("n")
+            presets.append(
+                {"id": preset_id, "name": name.strip() if isinstance(name, str) and name.strip() else str(preset_id)}
+            )
+        return sorted(presets, key=lambda item: int(item["id"]))
+
+    async def get_info(self, base_url: str) -> dict[str, str | None]:
+        """Return the optional name and version reported by WLED."""
+        response = await self.client.get(f"{base_url}/json/info")
+        response.raise_for_status()
+        try:
+            payload = response.json()
+        except ValueError as exc:
+            raise WLEDResponseError("info response is not valid JSON") from exc
+        if not isinstance(payload, dict):
+            raise WLEDResponseError("info response must be a JSON object")
+
+        def clean(value) -> str | None:
+            return value.strip() if isinstance(value, str) and value.strip() else None
+
+        return {"name": clean(payload.get("name")), "version": clean(payload.get("ver"))}
+
+    async def shutdown(self) -> None:
+        tasks: list[asyncio.Task] = []
+        for runtime in self._runtime.values():
+            for task in (runtime.send_task, runtime.finished_task):
+                if task and not task.done():
+                    task.cancel()
+                    tasks.append(task)
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        self._runtime.clear()
+        self._configs.clear()
+        if self._owns_client and self._client is not None:
+            await self._client.aclose()
+        self._client = None
+
+    async def _finish_timeout(
+        self,
+        printer_id: int,
+        generation: int,
+        base_url: str,
+        idle_preset_id: int,
+        timeout_seconds: int,
+    ) -> None:
+        try:
+            await asyncio.sleep(timeout_seconds)
+            runtime = self._runtime.get(printer_id)
+            if not runtime or runtime.generation != generation or not runtime.target or runtime.target[0] != "finished":
+                return
+            runtime.finished_timeout_expired = True
+            await self._send_finished_idle(printer_id, generation, base_url, idle_preset_id)
+        except asyncio.CancelledError:
+            return
+
+    def _cancel_runtime(self, printer_id: int, *, remove: bool = False) -> None:
+        runtime = self._runtime.get(printer_id)
+        if runtime:
+            runtime.generation += 1
+            self._cancel_task(runtime.send_task)
+            self._cancel_task(runtime.finished_task)
+        if remove:
+            self._runtime.pop(printer_id, None)
+
+    @staticmethod
+    def _cancel_task(task: asyncio.Task | None) -> None:
+        if task and not task.done():
+            task.cancel()
+
+
+wled_manager = WLEDManager()

+ 61 - 0
backend/tests/integration/test_printer_scope_1727.py

@@ -92,6 +92,67 @@ async def _listed_ids(async_client: AsyncClient, headers: dict[str, str]) -> set
     return {p["id"] for p in response.json()}
 
 
+@pytest.mark.parametrize(
+    ("endpoint", "method", "result"),
+    [
+        ("presets", "list_presets", [{"id": 3, "name": "Printing"}]),
+        ("test-connection", "get_info", {"name": "WLED", "version": "16.0.0"}),
+        ("test-preset", "send_preset", True),
+    ],
+)
+class TestWLEDPrinterScope:
+    async def test_auth_disabled_allows_wled(self, async_client, printer_factory, endpoint, method, result):
+        printer = await printer_factory(name="WLED")
+        with patch(
+            f"backend.app.api.routes.printers.wled_manager.{method}", new=AsyncMock(return_value=result)
+        ) as call:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/{endpoint}",
+                json={"base_url": "http://wled.local", "preset_id": 3},
+            )
+        assert response.status_code == 200, response.text
+        call.assert_awaited_once()
+
+    async def test_auth_enabled_requires_printers_update(self, async_client, printer_factory, endpoint, method, result):
+        printer = await printer_factory(name="WLED")
+        admin = await _admin_token(async_client)
+        readers = await _group(async_client, admin, "readers", permissions=["printers:read"], printer_ids=[printer.id])
+        jwt, _ = await _user(async_client, admin, "reader", [readers])
+        url = f"/api/v1/printers/{printer.id}/wled/{endpoint}"
+        body = {"base_url": "http://wled.local", "preset_id": 3}
+        with patch(
+            f"backend.app.api.routes.printers.wled_manager.{method}", new=AsyncMock(return_value=result)
+        ) as call:
+            assert (await async_client.post(url, json=body)).status_code == 401
+            response = await async_client.post(url, headers=_auth(jwt), json=body)
+            assert response.status_code == 403, response.text
+            call.assert_not_awaited()
+
+    async def test_update_permission_is_limited_to_the_groups_printers(
+        self, async_client, printer_factory, endpoint, method, result
+    ):
+        allowed = await printer_factory(name="Allowed")
+        hidden = await printer_factory(name="Hidden")
+        admin = await _admin_token(async_client)
+        permissions = await _group(async_client, admin, "editors", permissions=["printers:update"])
+        team = await _group(async_client, admin, "team", printer_ids=[allowed.id])
+        jwt, _ = await _user(async_client, admin, "editor", [permissions, team])
+        body = {"base_url": "http://wled.local", "preset_id": 3}
+        with patch(
+            f"backend.app.api.routes.printers.wled_manager.{method}", new=AsyncMock(return_value=result)
+        ) as call:
+            denied = await async_client.post(
+                f"/api/v1/printers/{hidden.id}/wled/{endpoint}", headers=_auth(jwt), json=body
+            )
+            assert denied.status_code == 404, denied.text
+            call.assert_not_awaited()
+            response = await async_client.post(
+                f"/api/v1/printers/{allowed.id}/wled/{endpoint}", headers=_auth(jwt), json=body
+            )
+            assert response.status_code == 200, response.text
+            call.assert_awaited_once()
+
+
 class TestVisibility:
     async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
         a, b = await printer_factory(name="A"), await printer_factory(name="B")

+ 187 - 0
backend/tests/integration/test_printers_api.py

@@ -6,6 +6,7 @@ Tests the full request/response cycle for /api/v1/printers/ endpoints.
 from unittest.mock import AsyncMock, MagicMock, patch
 from urllib.parse import unquote
 
+import httpx
 import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
@@ -75,6 +76,11 @@ class TestPrintersAPI:
             "access_code": "12345678",
             "is_active": True,
             "model": "X1C",
+            "wled_config": {
+                "enabled": True,
+                "base_url": "http://wled.local/",
+                "presets": {"printing": 3},
+            },
         }
 
         response = await async_client.post("/api/v1/printers/", json=data)
@@ -84,6 +90,8 @@ class TestPrintersAPI:
         assert result["name"] == "New Printer"
         assert result["serial_number"] == "00M09A111111111"
         assert result["model"] == "X1C"
+        assert result["wled_config"]["base_url"] == "http://wled.local"
+        assert result["wled_config"]["presets"]["printing"] == 3
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -244,6 +252,36 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.json()["is_active"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deactivate_printer_requests_wled_offline(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(is_active=True)
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.disconnect_printer") as disconnect,
+            patch("backend.app.api.routes.printers.wled_manager.handle_offline") as offline,
+        ):
+            response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
+        assert response.status_code == 200
+        disconnect.assert_called_once_with(printer.id)
+        offline.assert_called_once_with(printer.id)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_manual_disconnect_requests_wled_offline_without_status_callback(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        printer = await printer_factory(is_active=True)
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.disconnect_printer") as disconnect,
+            patch("backend.app.api.routes.printers.wled_manager.handle_offline") as offline,
+            patch("backend.app.api.routes.printers.printer_manager._on_status_change") as status_callback,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/disconnect")
+        assert response.status_code == 200
+        disconnect.assert_called_once_with(printer.id)
+        offline.assert_called_once_with(printer.id)
+        status_callback.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
@@ -255,6 +293,155 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.json()["auto_archive"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wled_config_round_trip_and_clear(self, async_client: AsyncClient, printer_factory, db_session):
+        printer = await printer_factory(name="WLED Printer")
+        config = {
+            "enabled": True,
+            "base_url": "http://wled.local/",
+            "presets": {"idle": 1, "printing": 3, "finished": 9},
+            "finished_timeout_seconds": 120,
+        }
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.disconnect_printer") as disconnect,
+            patch("backend.app.api.routes.printers.printer_manager.connect_printer", new=AsyncMock()) as connect,
+        ):
+            response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wled_config": config})
+
+        disconnect.assert_not_called()
+        connect.assert_not_awaited()
+
+        assert response.status_code == 200
+        assert response.json()["wled_config"] == {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {
+                "idle": 1,
+                "prepare": None,
+                "printing": 3,
+                "paused": None,
+                "finished": 9,
+                "error": None,
+                "queue_waiting": None,
+                "filament_problem": None,
+                "hms_error": None,
+                "offline": None,
+            },
+            "finished_timeout_seconds": 120,
+        }
+        assert (await async_client.get(f"/api/v1/printers/{printer.id}")).json()["wled_config"] == response.json()[
+            "wled_config"
+        ]
+
+        cleared = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wled_config": None})
+        assert cleared.status_code == 200
+        assert cleared.json()["wled_config"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_wled_url_does_not_break_reads_or_weaken_input_validation(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        printer = await printer_factory(name="Legacy WLED")
+        unsafe_url = "http://169.254.169.254/latest/meta-data"
+        printer.wled_config = {"enabled": True, "base_url": unsafe_url, "presets": {"printing": 3}}
+        await db_session.commit()
+
+        listed = await async_client.get("/api/v1/printers/")
+        assert listed.status_code == 200
+        assert next(p for p in listed.json() if p["id"] == printer.id)["wled_config"]["base_url"] == unsafe_url
+
+        detail = await async_client.get(f"/api/v1/printers/{printer.id}")
+        assert detail.status_code == 200
+        assert detail.json()["wled_config"]["base_url"] == unsafe_url
+
+        rejected = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wled_config": printer.wled_config})
+        assert rejected.status_code == 422
+
+        rejected_create = await async_client.post(
+            "/api/v1/printers/",
+            json={
+                "name": "Unsafe WLED",
+                "serial_number": "00M09A999999999",
+                "ip_address": "192.168.1.99",
+                "access_code": "12345678",
+                "wled_config": printer.wled_config,
+            },
+        )
+        assert rejected_create.status_code == 422
+
+        printer.wled_config = {"enabled": True, "base_url": "http://user:secret@wled.local"}
+        await db_session.commit()
+        redacted = await async_client.get(f"/api/v1/printers/{printer.id}")
+        assert redacted.status_code == 200
+        assert redacted.json()["wled_config"]["base_url"] == "http://user:[REDACTED]@wled.local"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_wled_presets(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="WLED Printer")
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.list_presets",
+            new=AsyncMock(return_value=[{"id": 3, "name": "Printing Blue"}]),
+        ) as list_presets:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/presets",
+                json={"base_url": "http://wled.local/"},
+            )
+
+        assert response.status_code == 200
+        assert response.json() == [{"id": 3, "name": "Printing Blue"}]
+        list_presets.assert_awaited_once_with("http://wled.local")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_wled_presets_reports_offline(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Offline WLED")
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.list_presets",
+            new=AsyncMock(side_effect=httpx.ConnectError("offline")),
+        ):
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/presets",
+                json={"base_url": "http://wled.local"},
+            )
+
+        assert response.status_code == 502
+        assert response.json()["detail"] == "Could not load WLED presets"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wled_connection_and_preset_test(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Test WLED")
+
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.get_info",
+            new=AsyncMock(return_value={"name": "Kitchen LEDs", "version": "0.15.0"}),
+        ) as get_info:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/test-connection",
+                json={"base_url": "http://wled.local/"},
+            )
+
+        assert response.status_code == 200
+        assert response.json() == {"name": "Kitchen LEDs", "version": "0.15.0"}
+        get_info.assert_awaited_once_with("http://wled.local")
+
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.send_preset",
+            new=AsyncMock(return_value=True),
+        ) as send_preset:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/test-preset",
+                json={"base_url": "http://wled.local/", "preset_id": 3},
+            )
+
+        assert response.status_code == 200
+        assert response.json() == {"success": True}
+        send_preset.assert_awaited_once_with(printer.id, "http://wled.local", 3)
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_nonexistent_printer(self, async_client: AsyncClient):

+ 14 - 0
backend/tests/integration/test_settings_api.py

@@ -43,6 +43,20 @@ class TestSettingsAPI:
         assert isinstance(result["auto_archive"], bool)
         assert isinstance(result["currency"], str)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_require_plate_clear_updates_wled_mapping(self, async_client: AsyncClient, monkeypatch):
+        from backend.app.services.wled import wled_manager
+
+        monkeypatch.setattr(wled_manager, "require_plate_clear", False)
+        enabled = await async_client.patch("/api/v1/settings/", json={"require_plate_clear": True})
+        assert enabled.status_code == 200
+        assert wled_manager.require_plate_clear is True
+
+        reset = await async_client.post("/api/v1/settings/reset")
+        assert reset.status_code == 200
+        assert wled_manager.require_plate_clear is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_unset_temp_alarm_reads_back_as_null(self, async_client: AsyncClient, db_session):

+ 860 - 0
backend/tests/unit/services/test_wled.py

@@ -0,0 +1,860 @@
+import asyncio
+import logging
+
+import httpx
+import pytest
+
+from backend.app.services.bambu_mqtt import HMSError, PrinterState
+from backend.app.services.wled import WLEDManager, WLEDResponseError, effective_wled_status
+
+
+def _state(state: str, **values) -> PrinterState:
+    return PrinterState(connected=values.pop("connected", True), state=state, **values)
+
+
+def _manager(handler) -> tuple[WLEDManager, httpx.AsyncClient]:
+    client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+    return WLEDManager(client), client
+
+
+def test_effective_status_uses_existing_bambuddy_states_and_priorities():
+    assert effective_wled_status(_state("IDLE")) == "idle"
+    assert effective_wled_status(_state("RUNNING")) == "printing"
+    assert effective_wled_status(_state("PRINTING")) == "printing"
+    assert effective_wled_status(_state("PREPARE")) == "prepare"
+    assert effective_wled_status(_state("SLICING")) == "prepare"
+    assert effective_wled_status(_state("PAUSE")) == "paused"
+    assert effective_wled_status(_state("FINISH")) == "finished"
+    assert effective_wled_status(_state("FAILED")) == "error"
+    assert effective_wled_status(_state("IDLE"), awaiting_plate_clear=True, require_plate_clear=True) == "queue_waiting"
+    assert effective_wled_status(_state("PAUSE", stg_cur=6)) == "filament_problem"
+    assert effective_wled_status(_state("PAUSE", stg_cur=16)) == "paused"
+    assert effective_wled_status(_state("PAUSE", stg_cur=16, ams_status_main=1, mc_print_sub_stage=3)) == "paused"
+    assert (
+        effective_wled_status(_state("PAUSE", stg_cur=6, ams_status_main=0, mc_print_sub_stage=0)) == "filament_problem"
+    )
+    fault = HMSError(code="0300_0008", attr=0x03000200, module=3, severity=1, description="Nozzle temperature abnormal")
+    assert effective_wled_status(_state("IDLE", hms_errors=[fault])) == "hms_error"
+    assert effective_wled_status(_state("RUNNING", connected=False)) == "offline"
+    assert effective_wled_status(_state("UNKNOWN")) is None
+
+
+@pytest.mark.parametrize(
+    ("state", "awaiting_plate_clear", "require_plate_clear", "expected"),
+    [
+        ("FINISH", True, False, "finished"),
+        ("FAILED", True, False, "error"),
+        ("IDLE", True, False, "idle"),
+        ("FINISH", True, True, "queue_waiting"),
+        ("FAILED", True, True, "queue_waiting"),
+        ("IDLE", True, True, "queue_waiting"),
+        ("FINISH", False, True, "finished"),
+        ("FAILED", False, True, "error"),
+        ("RUNNING", True, True, "printing"),
+        ("PRINTING", True, True, "printing"),
+        ("PREPARE", True, True, "prepare"),
+        ("SLICING", True, True, "prepare"),
+        ("PAUSE", True, True, "paused"),
+        ("UNKNOWN", True, True, None),
+    ],
+)
+def test_plate_gate_precedes_only_idle_finished_and_error(state, awaiting_plate_clear, require_plate_clear, expected):
+    assert (
+        effective_wled_status(
+            _state(state), awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=require_plate_clear
+        )
+        == expected
+    )
+
+
+@pytest.mark.parametrize(
+    ("severity", "description", "actions", "full_code", "expected"),
+    [
+        (0, "Invalid level", ["OK_BUTTON"], "0500000000004038", "printing"),
+        (2, None, [], "0C0001000002001B", "printing"),
+        (3, "The top cover is open", [], "0300970000030001", "printing"),
+        (1, "Nozzle temperature abnormal", [], "0300020000010008", "hms_error"),
+        (2, "Filament fault", [], "0500060000020005", "hms_error"),
+        (3, None, ["OK_BUTTON"], "0300970000030001", "hms_error"),
+        (3, "Unable to start drying", [], "1880C003", "hms_error"),
+    ],
+)
+def test_hms_filter_matches_notifications(severity, description, actions, full_code, expected):
+    fault = HMSError(
+        code="fault", attr=0, module=0, severity=severity, description=description, actions=actions, full_code=full_code
+    )
+    assert effective_wled_status(_state("RUNNING", hms_errors=[fault])) == expected
+
+
+def test_specific_fault_priorities_are_preserved_with_plate_gate():
+    ignored = HMSError(code="ignored", attr=0, module=0, severity=0)
+    fault = HMSError(code="fault", attr=0, module=0, severity=2, description="Filament fault")
+    for state in ("RUNNING", "FINISH", "FAILED", "IDLE", "PAUSE"):
+        assert (
+            effective_wled_status(
+                _state(state, hms_errors=[ignored, fault]), awaiting_plate_clear=True, require_plate_clear=True
+            )
+            == "hms_error"
+        )
+    assert (
+        effective_wled_status(
+            _state("PAUSE", stg_cur=6, hms_errors=[fault]), awaiting_plate_clear=True, require_plate_clear=True
+        )
+        == "filament_problem"
+    )
+    assert effective_wled_status(_state("PAUSE", stg_cur=16, hms_errors=[fault])) == "hms_error"
+    assert (
+        effective_wled_status(
+            _state("FINISH", connected=False, hms_errors=[fault]), awaiting_plate_clear=True, require_plate_clear=True
+        )
+        == "offline"
+    )
+
+
+@pytest.mark.asyncio
+async def test_list_presets_returns_only_valid_named_slots():
+    manager, client = _manager(
+        lambda request: httpx.Response(
+            200,
+            json={"0": {}, "3": {"n": "Printing Blue"}, "7": {"n": ""}, "251": {"n": "No"}},
+        )
+    )
+    assert await manager.list_presets("http://wled.local") == [
+        {"id": 3, "name": "Printing Blue"},
+        {"id": 7, "name": "7"},
+    ]
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "response",
+    [httpx.Response(200, text="not-json"), httpx.Response(200, json=[])],
+)
+async def test_list_presets_rejects_invalid_responses(response):
+    manager, client = _manager(lambda request: response)
+    with pytest.raises(WLEDResponseError):
+        await manager.list_presets("http://wled.local")
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_get_info_returns_optional_wled_identity_and_rejects_invalid_response():
+    manager, client = _manager(lambda request: httpx.Response(200, json={"name": " Kitchen LEDs ", "ver": "0.15.0"}))
+    assert await manager.get_info("http://wled.local") == {"name": "Kitchen LEDs", "version": "0.15.0"}
+    await client.aclose()
+
+    invalid, invalid_client = _manager(lambda request: httpx.Response(200, json=[]))
+    with pytest.raises(WLEDResponseError):
+        await invalid.get_info("http://wled.local")
+    await invalid_client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_send_preset_posts_expected_payload_and_handles_failures(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    requests = []
+
+    def handler(request: httpx.Request):
+        requests.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    assert await manager.send_preset(1, "http://wled.local", 3) is True
+    assert requests[0].url == httpx.URL("http://wled.local/json/state")
+    assert requests[0].content == b'{"ps":3}'
+    assert (logging.INFO, "[WLED] Printer 1 state=manual -> preset=3") in [
+        (record.levelno, record.message) for record in caplog.records
+    ]
+    caplog.clear()
+
+    for response in (httpx.Response(500), httpx.Response(200, text="bad")):
+        failing, failing_client = _manager(lambda request, result=response: result)
+        assert await failing.send_preset(1, "http://wled.local", 3) is False
+        await failing_client.aclose()
+
+    offline, offline_client = _manager(lambda request: (_ for _ in ()).throw(httpx.ConnectError("offline")))
+    assert await offline.send_preset(1, "http://wled.local", 3) is False
+    records = [record for record in caplog.records if record.name == "backend.app.services.wled"]
+    assert len(records) == 3
+    assert all(record.levelno == logging.WARNING and "preset=3 failed" in record.message for record in records)
+    assert all("wled.local" not in record.message for record in records)
+    await offline_client.aclose()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_status_logging_follows_successful_sends_without_poll_duplicates(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    manager, client = _manager(lambda request: httpx.Response(200, json={"success": True}))
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"printing": 32, "finished": 34, "queue_waiting": 36},
+        },
+    )
+    manager.require_plate_clear = True
+    for state, awaiting in (("RUNNING", False), ("FINISH", False), ("FINISH", True)):
+        manager.handle_status(1, _state(state), awaiting_plate_clear=awaiting)
+        await asyncio.sleep(0)
+        manager.handle_status(1, _state(state), awaiting_plate_clear=awaiting)
+        await asyncio.sleep(0)
+    assert [
+        (record.levelno, record.message) for record in caplog.records if record.name == "backend.app.services.wled"
+    ] == [
+        (logging.INFO, "[WLED] Printer 1 state=printing -> preset=32"),
+        (logging.INFO, "[WLED] Printer 1 state=finished -> preset=34"),
+        (logging.INFO, "[WLED] Printer 1 state=queue_waiting -> preset=36"),
+    ]
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_cancelled_preset_send_does_not_log_a_switch_or_failure(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+
+    async def handler(request):
+        raise asyncio.CancelledError
+
+    manager, client = _manager(handler)
+    with pytest.raises(asyncio.CancelledError):
+        await manager.send_preset(1, "http://wled.local", 32, status="printing")
+    assert not [record for record in caplog.records if record.name == "backend.app.services.wled"]
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_status_changes_are_deduplicated_and_missing_or_disabled_mappings_do_nothing():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    manager.handle_status(1, _state("RUNNING", progress=50))
+    await asyncio.sleep(0)
+    assert len(sent) == 1
+
+    manager.handle_status(1, _state("PAUSE"))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+
+    manager.handle_status(1, _state("IDLE"))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+
+    manager.configure_printer(2, {"enabled": False, "base_url": "http://wled.local", "presets": {"idle": 1}})
+    manager.handle_status(2, _state("IDLE"))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_status_send_retries_after_the_interval_then_deduplicates_success(caplog, monkeypatch):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    now = [1000.0]
+    monkeypatch.setattr("backend.app.services.wled.time.monotonic", lambda: now[0])
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(503 if len(sent) <= 2 else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 1
+    assert manager._runtime[1].status is None
+    assert manager._runtime[1].preset_id is None
+
+    # Status updates arrive every second or so: none of them retries before
+    # the interval is over.
+    for _ in range(5):
+        now[0] += 1
+        manager.handle_status(1, _state("RUNNING"))
+        await asyncio.sleep(0)
+    assert len(sent) == 1
+
+    now[0] += 30
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 2
+
+    now[0] += 30
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 3
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+
+    manager.handle_status(1, _state("RUNNING"))
+    await asyncio.sleep(0)
+    assert len(sent) == 3
+    messages = [
+        (record.levelno, record.message) for record in caplog.records if record.name == "backend.app.services.wled"
+    ]
+    # One warning for the whole outage, not one per retry.
+    assert messages == [
+        (logging.WARNING, "[WLED] Printer 1 state=printing -> preset=3 failed (HTTP 503)"),
+        (logging.INFO, "[WLED] Printer 1: retrying every 30s until WLED answers"),
+        (logging.INFO, "[WLED] Printer 1 state=printing -> preset=3"),
+        (logging.INFO, "[WLED] Printer 1: WLED answers again"),
+    ]
+    assert all("wled.local" not in message for _, message in messages)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_a_new_state_is_sent_at_once_during_an_outage_without_a_new_warning(caplog, monkeypatch):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    monkeypatch.setattr("backend.app.services.wled.time.monotonic", lambda: 1000.0)
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(503)
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}}
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("PAUSE"))
+    await manager._runtime[1].send_task
+    assert sent == [b'{"ps":3}', b'{"ps":7}']
+    warnings = [record for record in caplog.records if record.levelno == logging.WARNING]
+    assert len(warnings) == 1
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_identical_status_does_not_start_parallel_sends():
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def handler(request: httpx.Request):
+        sent.append(request)
+        started.set()
+        await release.wait()
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+
+    manager.handle_status(1, _state("RUNNING", progress=20))
+    manager.handle_status(1, _state("RUNNING", progress=30))
+    assert manager._runtime[1].send_task is task
+    assert manager._runtime[1].status is None
+    assert len(sent) == 1
+
+    release.set()
+    await task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_return_to_previously_confirmed_state_still_retries():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(503 if len(sent) == 2 else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.retry_seconds = 0
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert manager._runtime[1].status == "printing"
+
+    manager.handle_status(1, _state("PAUSE"))
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 2
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    assert manager._runtime[1].confirmed_generation != manager._runtime[1].generation
+
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 3
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_manual_preset_restores_automatic_mapping_once_on_next_status():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+
+    assert await manager.test_preset(1, "http://wled.local", 11) is True
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("RUNNING"))
+    await asyncio.sleep(0)
+    assert sent == [b'{"ps":3}', b'{"ps":11}', b'{"ps":3}']
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_manual_preset_keeps_automatic_mapping_retryable():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(503 if request.content == b'{"ps":11}' else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+
+    assert await manager.test_preset(1, "http://wled.local", 11) is False
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert sent == [b'{"ps":3}', b'{"ps":11}', b'{"ps":3}']
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_manual_preset_invalidates_slow_automatic_send(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        sent.append(preset_id)
+        if preset_id == 3 and sent.count(3) == 1:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    old_task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+
+    assert await manager.test_preset(1, "http://wled.local", 11) is True
+    release.set()
+    await old_task
+    assert manager._runtime[1].status is None
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert sent == [3, 11, 3]
+    assert manager._runtime[1].status == "printing"
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_status_change_during_manual_send_restores_new_mapping(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        sent.append(preset_id)
+        if preset_id == 11:
+            started.set()
+            await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+
+    manual_task = asyncio.create_task(manager.test_preset(1, "http://wled.local", 11))
+    await asyncio.wait_for(started.wait(), 1)
+    manager.handle_status(1, _state("PAUSE"))
+    await manager._runtime[1].send_task
+    assert manager._runtime[1].status == "paused"
+
+    release.set()
+    assert await manual_task is True
+    manager.handle_status(1, _state("PAUSE"))
+    await manager._runtime[1].send_task
+    assert sent == [3, 11, 7, 7]
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_offline_lifecycle_send_and_reconnect_restore_mapping():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "offline": 9}}
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("offline", 9)
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("RUNNING"))
+    await asyncio.sleep(0)
+    assert sent == [b'{"ps":3}', b'{"ps":9}', b'{"ps":3}']
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_offline_lifecycle_skips_disabled_or_missing_preset():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.handle_offline(1)
+    manager.configure_printer(1, {"enabled": False, "base_url": "http://wled.local", "presets": {"offline": 9}})
+    manager.handle_offline(1)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_offline(1)
+    await asyncio.sleep(0)
+    assert sent == []
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_offline_lifecycle_http_failure_does_not_block_and_is_retryable(caplog):
+    manager, client = _manager(lambda request: httpx.Response(503, json={"error": "offline"}))
+    manager.retry_seconds = 0
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 9}})
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    assert manager._runtime[1].confirmed_generation is None
+    assert "state=offline -> preset=9 failed (HTTP 503)" in caplog.text
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_late_automatic_send_cannot_confirm_over_offline(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        sent.append(preset_id)
+        if preset_id == 3:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "offline": 9}}
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    old_task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    release.set()
+    await old_task
+    assert sent == [3, 9]
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("offline", 9)
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_late_old_send_cannot_confirm_over_new_state(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        if preset_id == 3:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    old_task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+
+    manager.handle_status(1, _state("PAUSE"))
+    assert old_task.cancelled() or old_task.cancelling()
+    await manager._runtime[1].send_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
+
+    release.set()
+    await old_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("raw_state", "stage", "hms", "gate", "required", "presets", "expected_state", "expected_preset"),
+    [
+        ("PAUSE", 6, False, False, True, {"paused": 7}, "paused", 7),
+        ("PAUSE", 6, True, False, True, {"hms_error": 8, "paused": 7}, "hms_error", 8),
+        ("PAUSE", 16, False, False, True, {"filament_problem": 6, "paused": 7}, "paused", 7),
+        ("RUNNING", -1, True, False, True, {"printing": 3}, "printing", 3),
+        ("FINISH", -1, False, True, False, {"queue_waiting": 5, "finished": 9}, "finished", 9),
+        ("FINISH", -1, False, True, True, {"queue_waiting": 5, "finished": 9}, "queue_waiting", 5),
+        ("FINISH", -1, False, True, True, {"finished": 9}, "finished", 9),
+        ("FAILED", -1, False, True, True, {"error": 10}, "error", 10),
+        ("IDLE", -1, False, True, True, {"idle": 1}, "idle", 1),
+        ("FINISH", -1, False, True, True, {"idle": 1}, None, None),
+        ("RUNNING", -1, False, False, True, {"idle": 1}, None, None),
+    ],
+)
+async def test_missing_preset_falls_back_only_to_applicable_states(
+    raw_state, stage, hms, gate, required, presets, expected_state, expected_preset
+):
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.require_plate_clear = required
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": presets})
+    fault = HMSError(code="fault", attr=0, module=0, severity=1, description="Nozzle fault")
+    manager.handle_status(
+        1,
+        _state(raw_state, stg_cur=stage, hms_errors=[fault] if hms else []),
+        awaiting_plate_clear=gate,
+    )
+    await asyncio.sleep(0)
+
+    assert [request.content for request in sent] == (
+        [f'{{"ps":{expected_preset}}}'.encode()] if expected_preset else []
+    )
+    assert manager._runtime[1].status == expected_state
+    await manager.shutdown()
+    await client.aclose()
+
+
+def test_invalid_persisted_config_is_treated_as_disabled():
+    manager = WLEDManager()
+    manager.configure_printer(1, {"enabled": True, "base_url": None})
+    assert manager._configs[1].enabled is False
+
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://169.254.169.254/latest/meta-data", "presets": {"printing": 3}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    assert manager._configs[1].enabled is False
+    assert manager._runtime == {}
+    assert manager._client is None
+
+
+@pytest.mark.asyncio
+async def test_finished_timeout_confirms_idle_until_new_print_cycle(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1, "printing": 3},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    await manager._runtime[1].send_task
+    runtime = manager._runtime[1]
+    await manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0)
+    assert [request.content for request in sent] == [b'{"ps":9}', b'{"ps":1}']
+    assert "[WLED] Printer 1 state=idle -> preset=1" in caplog.messages
+    assert (runtime.status, runtime.preset_id) == ("idle", 1)
+
+    manager.handle_status(1, _state("FINISH", progress=100))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+
+    manager.handle_status(1, _state("IDLE"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("FINISH"))
+    await asyncio.sleep(0)
+    assert [request.content for request in sent].count(b'{"ps":9}') == 1
+
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert sent[-1].content == b'{"ps":3}'
+    manager.handle_status(1, _state("FINISH"))
+    await manager._runtime[1].send_task
+    assert [request.content for request in sent].count(b'{"ps":9}') == 2
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_finished_timeout_cancels_when_printing_resumes():
+    manager, client = _manager(lambda request: httpx.Response(200, json={"success": True}))
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1, "printing": 3},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    timer = manager._runtime[1].finished_task
+    manager.handle_status(1, _state("RUNNING"))
+    assert timer is not None and timer.cancelling()
+    await manager._runtime[1].send_task
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_finished_timeout_idle_send_is_retryable():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(503 if request.content == b'{"ps":1}' and len(sent) == 2 else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.retry_seconds = 0
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    runtime = manager._runtime[1]
+    await runtime.send_task
+    runtime.finished_task.cancel()
+    runtime.finished_task = asyncio.create_task(
+        manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0)
+    )
+    await runtime.finished_task
+    assert (runtime.status, runtime.preset_id) == ("finished", 9)
+
+    manager.handle_status(1, _state("FINISH"))
+    await runtime.finished_task
+    assert [request.content for request in sent] == [b'{"ps":9}', b'{"ps":1}', b'{"ps":1}']
+    assert (runtime.status, runtime.preset_id) == ("idle", 1)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_late_finished_timeout_idle_cannot_confirm_over_new_printing(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        if preset_id == 1:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1, "printing": 3},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    runtime = manager._runtime[1]
+    await runtime.send_task
+    runtime.finished_task.cancel()
+    old_timeout = asyncio.create_task(manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0))
+    runtime.finished_task = old_timeout
+    await asyncio.wait_for(started.wait(), 1)
+
+    manager.handle_status(1, _state("RUNNING"))
+    await runtime.send_task
+    assert (runtime.status, runtime.preset_id) == ("printing", 3)
+    release.set()
+    await old_timeout
+    assert (runtime.status, runtime.preset_id) == ("printing", 3)
+    await manager.shutdown()

+ 2 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -640,6 +640,8 @@ GUARDED_BODY_URLS = {
     ("OIDCProviderCreate", "icon_url"),
     ("OIDCProviderUpdate", "issuer_url"),
     ("OIDCProviderUpdate", "icon_url"),
+    ("WLEDPresetListRequest", "base_url"),  # WLEDConfig LAN-service guard
+    ("WLEDPresetTestRequest", "base_url"),  # Same validated WLED LAN-service URL
     # Gitea/Forgejo derive their API base from this and request it with the
     # stored token, so it is a real fetch target — guarded in
     # github_backup._enforce_private_repo, which both POST and PATCH funnel through.

+ 43 - 1
backend/tests/unit/test_printer_schema.py

@@ -9,7 +9,7 @@ the schema normalizes it on input.
 import pytest
 from pydantic import ValidationError
 
-from backend.app.schemas.printer import PrinterCreate
+from backend.app.schemas.printer import PrinterCreate, WLEDConfig, WLEDPresets
 
 
 def _make(serial: str) -> PrinterCreate:
@@ -40,3 +40,45 @@ def test_already_normalized_serial_unchanged():
 def test_blank_serial_number_rejected():
     with pytest.raises(ValidationError):
         _make("   ")
+
+
+def test_wled_config_normalizes_url_and_accepts_optional_mappings():
+    config = WLEDConfig(
+        enabled=True,
+        base_url=" http://wled.local/ ",
+        presets=WLEDPresets(printing=3, paused=None),
+        finished_timeout_seconds=120,
+    )
+
+    assert config.base_url == "http://wled.local"
+    assert config.presets.printing == 3
+    assert config.presets.paused is None
+
+
+@pytest.mark.parametrize("preset", [0, 251])
+def test_wled_preset_outside_supported_range_rejected(preset):
+    with pytest.raises(ValidationError):
+        WLEDPresets(idle=preset)
+
+
+def test_enabled_wled_requires_url():
+    with pytest.raises(ValidationError):
+        WLEDConfig(enabled=True)
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "file:///etc/passwd",
+        "http://169.254.169.254/latest/meta-data",
+        "http://user:password@wled.local",
+    ],
+)
+def test_wled_url_uses_lan_service_safety_policy(url):
+    with pytest.raises(ValidationError):
+        WLEDConfig(base_url=url)
+
+
+def test_wled_finished_timeout_is_bounded():
+    with pytest.raises(ValidationError):
+        WLEDConfig(finished_timeout_seconds=86401)

+ 30 - 0
backend/tests/unit/test_wled_config_migration.py

@@ -0,0 +1,30 @@
+"""Migration coverage for per-printer WLED configuration (#1528)."""
+
+import pytest
+from sqlalchemy import inspect, text
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+from backend.app.core.database import run_migrations
+from backend.app.models.printer import Printer
+
+
+@pytest.mark.asyncio
+async def test_wled_config_migration_is_additive_and_idempotent(test_engine):
+    session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
+    async with session_factory() as db:
+        db.add(Printer(name="Legacy", serial_number="WLED-LEGACY", ip_address="10.0.0.1", access_code="code"))
+        await db.commit()
+
+    async with test_engine.begin() as conn:
+        await conn.execute(text("ALTER TABLE printers DROP COLUMN wled_config"))
+        await run_migrations(conn)
+        await run_migrations(conn)
+        columns = await conn.run_sync(
+            lambda sync_conn: {col["name"] for col in inspect(sync_conn).get_columns("printers")}
+        )
+        stored = (
+            await conn.execute(text("SELECT wled_config FROM printers WHERE serial_number = 'WLED-LEGACY'"))
+        ).scalar_one()
+
+    assert "wled_config" in columns
+    assert stored is None

+ 48 - 0
backend/tests/unit/test_wled_startup.py

@@ -0,0 +1,48 @@
+"""Startup WLED initialization keeps inactive printers offline."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import initialize_wled_printers
+from backend.app.services.wled import WLEDManager
+
+
+@pytest.mark.asyncio
+async def test_startup_sets_only_inactive_printers_offline():
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=MagicMock())
+    inactive_config = {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 9}}
+    active_config = {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 10}}
+    db.execute.return_value.all.return_value = [
+        (1, inactive_config, False),
+        (2, active_config, True),
+    ]
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(return_value=False)),
+        patch("backend.app.main.wled_manager") as manager,
+    ):
+        await initialize_wled_printers(db)
+    assert manager.configure_printer.call_count == 2
+    manager.configure_printer.assert_any_call(1, inactive_config)
+    manager.configure_printer.assert_any_call(2, active_config)
+    manager.handle_offline.assert_called_once_with(1)
+
+
+@pytest.mark.asyncio
+async def test_startup_skips_missing_disabled_and_unmapped_wled_configs():
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=MagicMock())
+    db.execute.return_value.all.return_value = [
+        (1, None, False),
+        (2, {"enabled": False, "base_url": "http://wled.local", "presets": {"offline": 9}}, False),
+        (3, {"enabled": True, "base_url": "http://wled.local", "presets": {}}, False),
+    ]
+    manager = WLEDManager()
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(return_value=False)),
+        patch("backend.app.main.wled_manager", manager),
+    ):
+        await initialize_wled_printers(db)
+    assert all(runtime.send_task is None for runtime in manager._runtime.values())
+    assert manager._client is None

+ 767 - 0
frontend/src/__tests__/components/WLEDSettings.test.tsx

@@ -0,0 +1,767 @@
+import { describe, expect, it } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { useQueryClient } from '@tanstack/react-query';
+import { WLEDSettings } from '../../components/WLEDSettings';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+
+const printer = {
+  id: 1,
+  name: 'X1C Workshop',
+  serial_number: 'SERIAL1',
+  ip_address: '192.168.1.10',
+  model: 'X1C',
+  location: null,
+  nozzle_count: 1,
+  supports_nozzle_flow_type: true,
+  is_active: true,
+  auto_archive: true,
+  external_camera_url: null,
+  external_camera_type: null,
+  external_camera_enabled: false,
+  external_camera_snapshot_url: null,
+  camera_rotation: 0,
+  plate_detection_enabled: false,
+  created_at: '2026-01-01T00:00:00Z',
+  updated_at: '2026-01-01T00:00:00Z',
+  wled_config: {
+    enabled: false,
+    base_url: 'http://wled.local',
+    presets: {
+      idle: 1,
+      prepare: null,
+      printing: 99,
+      paused: null,
+      finished: 9,
+      error: null,
+      queue_waiting: null,
+      filament_problem: null,
+      hms_error: null,
+      offline: null,
+    },
+    finished_timeout_seconds: 120,
+  },
+};
+
+function deferred() {
+  let resolve!: () => void;
+  const promise = new Promise<void>((done) => { resolve = done; });
+  return { promise, resolve };
+}
+
+const enabledPrinter = { ...printer, wled_config: { ...printer.wled_config, enabled: true } };
+const secondPrinter = { ...enabledPrinter, id: 2, name: 'A1 Mini', wled_config: {
+  ...enabledPrinter.wled_config, base_url: 'http://second.local',
+} };
+
+function RefetchableWLEDSettings() {
+  const queryClient = useQueryClient();
+  return <>
+    <button onClick={() => void queryClient.refetchQueries({ queryKey: ['printers'] })}>Refetch printers</button>
+    <WLEDSettings />
+  </>;
+}
+
+describe('WLEDSettings', () => {
+  it('ignores a late A result after B has loaded its presets', async () => {
+    const oldA = deferred();
+    const calls: number[] = [];
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', async ({ params }) => {
+        const id = Number(params.id);
+        calls.push(id);
+        if (id === 1) await oldA.promise;
+        return HttpResponse.json([{ id, name: id === 1 ? 'Old A' : 'Current B' }]);
+      }),
+    );
+    render(<WLEDSettings />);
+    await waitFor(() => expect(calls).toContain(1));
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Current B (2)' })).toBeInTheDocument());
+    oldA.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).queryByRole('option', { name: 'Old A (1)' })).toBeNull());
+    expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Current B (2)' })).toBeInTheDocument();
+  });
+
+  it('ignores an older A request after A to B to A and keeps the newest same-context result', async () => {
+    const firstA = deferred();
+    const calls: number[] = [];
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', async ({ params }) => {
+        const id = Number(params.id);
+        calls.push(id);
+        const sequence = calls.length;
+        if (id === 1 && sequence === 1) await firstA.promise;
+        return HttpResponse.json([{ id: sequence, name: `Request ${sequence}` }]);
+      }),
+    );
+    render(<WLEDSettings />);
+    await waitFor(() => expect(calls).toEqual([1]));
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '77');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await waitFor(() => expect(calls).toEqual([1, 2]));
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Request 3 (3)' })).toBeInTheDocument());
+    firstA.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).queryByRole('option', { name: 'Request 1 (1)' })).toBeNull());
+    expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Request 3 (3)' })).toBeInTheDocument();
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(77);
+  });
+
+  it('ignores a late preset error after switching printer or URL', async () => {
+    const oldRequest = deferred();
+    let calls = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', async ({ params }) => {
+        calls += 1;
+        if (Number(params.id) === 1) {
+          await oldRequest.promise;
+          return HttpResponse.json({ detail: 'Old URL rejected' }, { status: 422 });
+        }
+        return HttpResponse.json([{ id: 2, name: 'Current B' }]);
+      }),
+    );
+    render(<WLEDSettings />);
+    await waitFor(() => expect(calls).toBe(1));
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Current B (2)' })).toBeInTheDocument());
+    oldRequest.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    await waitFor(() => expect(screen.queryByText('Old URL rejected')).toBeNull());
+    expect(screen.queryByText('WLED is unavailable. Saved preset IDs are kept.')).toBeNull();
+  });
+
+  it('ignores a response for the previous URL on the same printer', async () => {
+    const oldRequest = deferred();
+    let calls = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', async () => {
+        calls += 1;
+        if (calls === 1) await oldRequest.promise;
+        return HttpResponse.json([{ id: calls, name: `Request ${calls}` }]);
+      }),
+    );
+    render(<WLEDSettings />);
+    await waitFor(() => expect(calls).toBe(1));
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://new.local');
+    oldRequest.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).queryByRole('option', { name: 'Request 1 (1)' })).toBeNull());
+    await userEvent.click(screen.getByRole('button', { name: 'Load presets' }));
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Request 2 (2)' })).toBeInTheDocument());
+  });
+
+  it('keeps the newest preset request for the same printer and URL', async () => {
+    const oldRequest = deferred();
+    let calls = 0;
+    let printerReads = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{
+        ...enabledPrinter, name: `Printer read ${++printerReads}`,
+      }])),
+      http.post('/api/v1/printers/1/wled/presets', async () => {
+        const sequence = ++calls;
+        if (sequence === 1) await oldRequest.promise;
+        return HttpResponse.json([{ id: sequence, name: `Request ${sequence}` }]);
+      }),
+    );
+    render(<RefetchableWLEDSettings />);
+    await waitFor(() => expect(calls).toBe(1));
+    await userEvent.click(screen.getByRole('button', { name: 'Refetch printers' }));
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'Request 2 (2)' })).toBeInTheDocument());
+    oldRequest.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(within(screen.getByLabelText('Printing')).queryByRole('option', { name: 'Request 1 (1)' })).toBeNull();
+  });
+
+  it('does not apply a pending result after unmount', async () => {
+    const pending = deferred();
+    let started = false;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', async () => {
+        started = true;
+        await pending.promise;
+        return HttpResponse.json({ detail: 'Old request failed' }, { status: 422 });
+      }),
+    );
+    const view = render(<WLEDSettings />);
+    await waitFor(() => expect(started).toBe(true));
+    view.unmount();
+    pending.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(screen.queryByText('Old request failed')).toBeNull();
+  });
+
+  it('does not replace newer local edits when an older save completes', async () => {
+    const pending = deferred();
+    let started = false;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async () => {
+        started = true;
+        await pending.promise;
+        return HttpResponse.json(enabledPrinter);
+      }),
+    );
+    render(<RefetchableWLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await waitFor(() => expect(started).toBe(true));
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://new.local');
+    pending.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    await userEvent.click(screen.getByRole('button', { name: 'Refetch printers' }));
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://new.local');
+  });
+
+  it('does not reload presets from the saved URL after a local URL edit and printer refetch', async () => {
+    let printerReads = 0;
+    let presetRequests = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{
+        ...enabledPrinter, name: `Printer read ${++printerReads}`,
+      }])),
+      http.post('/api/v1/printers/1/wled/presets', () => {
+        presetRequests += 1;
+        return HttpResponse.json([{ id: 3, name: 'Saved URL' }]);
+      }),
+    );
+    render(<RefetchableWLEDSettings />);
+    await screen.findByText('1 presets loaded');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://new.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Refetch printers' }));
+    await waitFor(() => expect(printerReads).toBe(2));
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(presetRequests).toBe(1);
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://new.local');
+  });
+
+  const validationError = () => HttpResponse.json({
+    detail: [{ msg: 'Value error, Invalid WLED URL' }],
+  }, { status: 422 });
+
+  it('shows the backend 422 message for Save without losing the input', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', validationError),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://invalid.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    expect(await screen.findByText('Invalid WLED URL')).toBeInTheDocument();
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://invalid.local');
+    expect(screen.queryByText('Could not save WLED settings')).toBeNull();
+    expect(screen.queryByText('Unable to connect to WLED.')).toBeNull();
+    expect(screen.queryByText('WLED is unavailable. Saved preset IDs are kept.')).toBeNull();
+  });
+
+  it('ignores a late Save 422 for an older draft snapshot', async () => {
+    const pending = deferred();
+    let sentTimeout: number | undefined;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async ({ request }) => {
+        const body = await request.json() as { wled_config: typeof enabledPrinter.wled_config };
+        sentTimeout = body.wled_config.finished_timeout_seconds;
+        await pending.promise;
+        return HttpResponse.json({ detail: 'Invalid finished timeout' }, { status: 422 });
+      }),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '10');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await waitFor(() => expect(sentTimeout).toBe(10));
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '20');
+    pending.resolve();
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
+    expect(screen.queryByText('Invalid finished timeout')).toBeNull();
+    expect(screen.queryByText('WLED settings saved')).toBeNull();
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(20);
+  });
+
+  it('shows a delayed Save 422 when the draft has not changed', async () => {
+    const pending = deferred();
+    let saveStarted = false;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async () => {
+        saveStarted = true;
+        await pending.promise;
+        return HttpResponse.json({ detail: 'Invalid finished timeout' }, { status: 422 });
+      }),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '10');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await waitFor(() => expect(saveStarted).toBe(true));
+    pending.resolve();
+    expect(await screen.findByText('Invalid finished timeout')).toBeInTheDocument();
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(10);
+  });
+
+  it('shows the backend 422 message for Load presets without marking WLED unavailable', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', validationError),
+    );
+    render(<WLEDSettings />);
+    expect(await screen.findByText('Invalid WLED URL')).toBeInTheDocument();
+    expect(screen.queryByText('WLED is unavailable. Saved preset IDs are kept.')).toBeNull();
+    expect(screen.getByLabelText('Printing')).toHaveValue('99');
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://wled.local');
+  });
+
+  it('shows the backend 422 message for Test connection without marking WLED unreachable', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([])),
+      http.post('/api/v1/printers/1/wled/test-connection', validationError),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+    expect(await screen.findByText('Invalid WLED URL')).toBeInTheDocument();
+    expect(screen.getByText('Not tested')).toBeInTheDocument();
+    expect(screen.queryByText('Unable to connect to WLED.')).toBeNull();
+  });
+
+  it('shows the backend 422 message for Test preset without a generic error', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([{ id: 99, name: 'Printing' }])),
+      http.post('/api/v1/printers/1/wled/test-preset', validationError),
+    );
+    render(<WLEDSettings />);
+    await screen.findByText('1 presets loaded');
+    await userEvent.click(screen.getByRole('button', { name: 'Test Printing' }));
+    expect(await screen.findByText('Invalid WLED URL')).toBeInTheDocument();
+    expect(screen.queryByText('Could not activate preset')).toBeNull();
+    expect(screen.getByLabelText('Printing')).toHaveValue('99');
+    expect(screen.queryByText('Unable to connect to WLED.')).toBeNull();
+    expect(screen.queryByText('WLED is unavailable. Saved preset IDs are kept.')).toBeNull();
+  });
+
+  it('ignores a late 422 from an old URL while showing the new URL', async () => {
+    const pending = deferred();
+    let calls = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', async () => {
+        calls += 1;
+        if (calls === 1) {
+          await pending.promise;
+          return validationError();
+        }
+        return HttpResponse.json([{ id: 2, name: 'New URL' }]);
+      }),
+    );
+    render(<WLEDSettings />);
+    await waitFor(() => expect(calls).toBe(1));
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://new.local');
+    pending.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(screen.queryByText('Invalid WLED URL')).toBeNull();
+    expect(screen.queryByText('WLED is unavailable. Saved preset IDs are kept.')).toBeNull();
+    await userEvent.click(screen.getByRole('button', { name: 'Load presets' }));
+    await waitFor(() => expect(within(screen.getByLabelText('Printing')).getByRole('option', { name: 'New URL (2)' })).toBeInTheDocument());
+  });
+
+  it('keeps generic 502 errors for Save and Test preset', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([{ id: 99, name: 'Printing' }])),
+      http.post('/api/v1/printers/1/wled/test-preset', () => HttpResponse.json({}, { status: 502 })),
+      http.patch('/api/v1/printers/1', () => HttpResponse.json({}, { status: 502 })),
+    );
+    render(<WLEDSettings />);
+    await screen.findByText('1 presets loaded');
+    await userEvent.click(screen.getByRole('button', { name: 'Test Printing' }));
+    expect(await screen.findByText('Could not activate preset')).toBeInTheDocument();
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    expect(await screen.findByText('Could not save WLED settings')).toBeInTheDocument();
+  });
+
+  it('keeps unsaved edits when the printers query refetches', async () => {
+    let requests = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        requests += 1;
+        return HttpResponse.json([{
+          ...printer,
+          name: requests === 1 ? printer.name : 'Refetched printer',
+          wled_config: { ...printer.wled_config, enabled: true },
+        }]);
+      }),
+      http.post('/api/v1/printers/1/wled/presets', () =>
+        HttpResponse.json([{ id: 3, name: 'Printing Cyan' }]),
+      ),
+    );
+    render(<RefetchableWLEDSettings />);
+    await screen.findByText('1 presets loaded');
+    const url = screen.getByLabelText('WLED URL');
+    await userEvent.clear(url);
+    await userEvent.type(url, 'http://edited.local');
+    await userEvent.selectOptions(screen.getByLabelText('Printing'), '3');
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '45');
+    await userEvent.click(screen.getByLabelText('Enable WLED integration'));
+    await userEvent.click(screen.getByRole('button', { name: 'Refetch printers' }));
+    await screen.findByRole('option', { name: 'Refetched printer' });
+
+    expect(requests).toBe(2);
+    expect(url).toHaveValue('http://edited.local');
+    expect(screen.getByLabelText('Enable WLED integration')).not.toBeChecked();
+    expect(screen.getByLabelText('Printing')).toHaveValue('3');
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(45);
+  });
+
+  it('loads each printers saved configuration once and keeps unsaved edits across switches', async () => {
+    const second = {
+      ...printer,
+      id: 2,
+      name: 'A1 Mini',
+      wled_config: {
+        ...printer.wled_config,
+        enabled: true,
+        base_url: 'http://second.local',
+        presets: { ...printer.wled_config.presets, printing: 32 },
+        finished_timeout_seconds: 60,
+      },
+    };
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([printer, second])),
+      http.post('/api/v1/printers/2/wled/presets', () => HttpResponse.json([{ id: 32, name: 'Printing Cyan' }])),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.click(screen.getByLabelText('Enable WLED integration'));
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://unsaved.local');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await screen.findByDisplayValue('http://second.local');
+    expect(screen.getByLabelText('Enable WLED integration')).toBeChecked();
+    expect(screen.getByLabelText('Printing')).toHaveValue('32');
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(60);
+
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    await screen.findByDisplayValue('http://unsaved.local');
+    expect(screen.getByLabelText('Enable WLED integration')).toBeChecked();
+    expect(screen.getByLabelText('Printing')).toHaveValue('99');
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(120);
+  });
+
+  it('keeps different unsaved drafts for A and B through repeated switches', async () => {
+    server.use(http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])));
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '11');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await screen.findByDisplayValue('http://second.local');
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '22');
+    for (let i = 0; i < 2; i += 1) {
+      await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+      expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(11);
+      await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+      expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(22);
+    }
+  });
+
+  it('uses newer server values after a successful save clears the unchanged draft', async () => {
+    let saved = enabledPrinter;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([saved])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async ({ request }) => {
+        const body = await request.json() as { wled_config: typeof enabledPrinter.wled_config };
+        saved = { ...enabledPrinter, wled_config: body.wled_config };
+        return HttpResponse.json(saved);
+      }),
+    );
+    render(<RefetchableWLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://saved.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await screen.findByText('WLED settings saved');
+    saved = { ...saved, wled_config: { ...saved.wled_config, base_url: 'http://external.local' } };
+    await userEvent.click(screen.getByRole('button', { name: 'Refetch printers' }));
+    await waitFor(() => expect(screen.getByLabelText('WLED URL')).toHaveValue('http://external.local'));
+  });
+
+  it('keeps a newer A draft after the older save succeeds', async () => {
+    const pending = deferred();
+    let saved: typeof enabledPrinter | null = null;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([saved ?? enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async ({ request }) => {
+        const body = await request.json() as { wled_config: typeof enabledPrinter.wled_config };
+        await pending.promise;
+        saved = { ...enabledPrinter, wled_config: body.wled_config };
+        return HttpResponse.json(saved);
+      }),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://version-one.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://version-two.local');
+    pending.resolve();
+    await waitFor(() => expect(saved?.wled_config.base_url).toBe('http://version-one.local'));
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://version-two.local');
+  });
+
+  it('keeps a newer non-URL edit unsaved when the older snapshot finishes saving', async () => {
+    const pending = deferred();
+    let sentTimeout: number | undefined;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async ({ request }) => {
+        const body = await request.json() as { wled_config: typeof enabledPrinter.wled_config };
+        sentTimeout = body.wled_config.finished_timeout_seconds;
+        await pending.promise;
+        return HttpResponse.json({ ...enabledPrinter, wled_config: body.wled_config });
+      }),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '10');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await waitFor(() => expect(sentTimeout).toBe(10));
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '20');
+    pending.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(screen.queryByText('WLED settings saved')).toBeNull();
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    expect(screen.getByLabelText('Finished timeout (seconds)')).toHaveValue(20);
+  });
+
+  it('does not change B when a save for A completes after switching', async () => {
+    const pending = deferred();
+    let saveStarted = false;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', async ({ request }) => {
+        saveStarted = true;
+        const body = await request.json() as { wled_config: typeof enabledPrinter.wled_config };
+        await pending.promise;
+        return HttpResponse.json({ ...enabledPrinter, wled_config: body.wled_config });
+      }),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://saved-a.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await waitFor(() => expect(saveStarted).toBe(true));
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await screen.findByDisplayValue('http://second.local');
+    pending.resolve();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://second.local');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://saved-a.local');
+  });
+
+  it('retains A draft after a failed save and switching away and back', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([enabledPrinter, secondPrinter])),
+      http.post('/api/v1/printers/:id/wled/presets', () => HttpResponse.json([])),
+      http.patch('/api/v1/printers/1', () => HttpResponse.json({}, { status: 502 })),
+    );
+    render(<WLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://unsaved-a.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    await screen.findByText('Could not save WLED settings');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '1');
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://unsaved-a.local');
+  });
+
+  it('selects another printer when the current one disappears from the query', async () => {
+    let listed = [enabledPrinter, secondPrinter];
+    server.use(http.get('/api/v1/printers/', () => HttpResponse.json(listed)));
+    render(<RefetchableWLEDSettings />);
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.selectOptions(screen.getByLabelText('Printer'), '2');
+    await screen.findByDisplayValue('http://second.local');
+    await userEvent.clear(screen.getByLabelText('WLED URL'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://draft-b.local');
+    listed = [enabledPrinter];
+    await userEvent.click(screen.getByRole('button', { name: 'Refetch printers' }));
+    await waitFor(() => expect(screen.getByLabelText('Printer')).toHaveValue('1'));
+    expect(screen.getByLabelText('WLED URL')).toHaveValue('http://wled.local');
+  });
+
+  it('keeps the integration card and disabled toggle visible without printers', async () => {
+    server.use(http.get('/api/v1/printers/', () => HttpResponse.json([])));
+    render(<WLEDSettings />);
+
+    expect(await screen.findByRole('heading', { name: 'WLED' })).toBeInTheDocument();
+    expect(screen.getByText('No printers configured.')).toBeInTheDocument();
+    expect(screen.getByLabelText('Enable WLED integration')).toBeDisabled();
+  });
+
+  it('keeps saved values visible and disabled until the integration is enabled', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+      http.post('/api/v1/printers/1/wled/presets', () =>
+        HttpResponse.json([{ id: 1, name: 'Idle White' }, { id: 9, name: 'Finished Green' }]),
+      ),
+    );
+    render(<WLEDSettings />);
+
+    const url = await screen.findByDisplayValue('http://wled.local');
+    const printing = screen.getByLabelText('Printing');
+    const timeout = screen.getByLabelText('Finished timeout (seconds)');
+    expect(url).toBeDisabled();
+    expect(printing).toBeDisabled();
+    expect(printing).toHaveValue('99');
+    expect(screen.getByText(/During runtime, Bambuddy only activates the configured preset/)).toBeInTheDocument();
+    expect(timeout).toBeDisabled();
+    expect(timeout).toHaveValue(120);
+
+    await userEvent.click(screen.getByLabelText('Enable WLED integration'));
+    expect(url).toBeEnabled();
+    expect(printing).toBeEnabled();
+    await userEvent.click(screen.getByRole('button', { name: 'Load presets' }));
+
+    expect(await screen.findByText('2 presets loaded')).toBeInTheDocument();
+    expect(screen.getByRole('option', { name: 'Saved preset 99 — currently unavailable' })).toBeInTheDocument();
+    expect(printing).toHaveValue('99');
+
+    await userEvent.click(screen.getByLabelText('Enable WLED integration'));
+    expect(url).toBeDisabled();
+    expect(printing).toBeDisabled();
+    expect(printing).toHaveValue('99');
+    expect(timeout).toHaveValue(120);
+  });
+
+  it('loads presets automatically for a saved enabled configuration', async () => {
+    let requests = 0;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{
+        ...printer,
+        wled_config: { ...printer.wled_config, enabled: true },
+      }])),
+      http.post('/api/v1/printers/1/wled/presets', () => {
+        requests += 1;
+        return HttpResponse.json([
+          { id: 1, name: 'Idle White' },
+          { id: 9, name: 'Finished Green' },
+          { id: 99, name: 'Printing Blue' },
+        ]);
+      }),
+    );
+    render(<WLEDSettings />);
+
+    expect(await screen.findByText('3 presets loaded')).toBeInTheDocument();
+    const printing = screen.getByLabelText('Printing');
+    expect(printing).toHaveValue('99');
+    expect(within(printing).getByRole('option', { name: 'Printing Blue (99)' })).toBeInTheDocument();
+    expect(requests).toBe(1);
+  });
+
+  it('edits and saves enabled, URL, preset and finished timeout', async () => {
+    let saved: unknown;
+    let testedPreset: unknown;
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ ...printer, wled_config: null }])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json([{ id: 3, name: 'Printing Blue' }])),
+      http.post('/api/v1/printers/1/wled/test-connection', () =>
+        HttpResponse.json({ name: 'Kitchen LEDs', version: '0.15.0' })),
+      http.post('/api/v1/printers/1/wled/test-preset', async ({ request }) => {
+        testedPreset = await request.json();
+        return HttpResponse.json({ success: true });
+      }),
+      http.patch('/api/v1/printers/1', async ({ request }) => {
+        saved = await request.json();
+        return HttpResponse.json({ ...printer, ...(saved as object) });
+      }),
+    );
+    render(<WLEDSettings />);
+
+    await screen.findByText('X1C Workshop');
+    await userEvent.click(screen.getByLabelText('Enable WLED integration'));
+    await userEvent.type(screen.getByLabelText('WLED URL'), 'http://wled.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+    expect(await screen.findByText('Connected to WLED (Kitchen LEDs) · v0.15.0')).toBeInTheDocument();
+    await userEvent.click(screen.getByRole('button', { name: 'Load presets' }));
+    await screen.findByText('1 presets loaded');
+    await userEvent.selectOptions(screen.getByLabelText('Printing'), '3');
+    expect(screen.getByText('Configured states: 1 · Disabled states: 9')).toBeInTheDocument();
+    await userEvent.click(screen.getByRole('button', { name: 'Test Printing' }));
+    await waitFor(() => expect(testedPreset).toEqual({ base_url: 'http://wled.local', preset_id: 3 }));
+    await userEvent.clear(screen.getByLabelText('Finished timeout (seconds)'));
+    await userEvent.type(screen.getByLabelText('Finished timeout (seconds)'), '30');
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+    await waitFor(() => expect(saved).toEqual({
+      wled_config: expect.objectContaining({
+        enabled: true,
+        base_url: 'http://wled.local',
+        presets: expect.objectContaining({ printing: 3 }),
+        finished_timeout_seconds: 30,
+      }),
+    }));
+  });
+
+  it('shows the offline state without clearing saved selections', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{
+        ...printer,
+        wled_config: { ...printer.wled_config, enabled: true },
+      }])),
+      http.post('/api/v1/printers/1/wled/presets', () => HttpResponse.json({}, { status: 502 })),
+      http.post('/api/v1/printers/1/wled/test-connection', () => HttpResponse.json({}, { status: 502 })),
+    );
+    render(<WLEDSettings />);
+
+    await screen.findByDisplayValue('http://wled.local');
+    await userEvent.click(screen.getByRole('button', { name: 'Load presets' }));
+    expect((await screen.findAllByText('WLED is unavailable. Saved preset IDs are kept.')).length).toBeGreaterThan(0);
+    expect(screen.getByLabelText('Printing')).toHaveValue('99');
+    await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+    expect(await screen.findByText('Unable to connect to WLED.')).toBeInTheDocument();
+  });
+});

+ 73 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -1680,6 +1680,79 @@ describe('SettingsPage', () => {
       render(<SettingsPage />);
       await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
     });
+
+    describe('WLED permission', () => {
+      const serveWledPrinter = (onPresetRequest: () => void) => server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([{
+          id: 1, name: 'Printer 1', wled_config: {
+            enabled: true, base_url: 'http://wled.local', presets: { printing: 3 }, finished_timeout_seconds: 0,
+          },
+        }])),
+        http.post('/api/v1/printers/1/wled/presets', () => {
+          onPresetRequest();
+          return HttpResponse.json([{ id: 3, name: 'Printing' }]);
+        }),
+      );
+
+      it.each([true, false])('shows WLED and loads presets when auth enabled=%s with access', async (authEnabled) => {
+        if (authEnabled) signInWith(['settings:read', 'printers:update']);
+        let presetRequests = 0;
+        serveWledPrinter(() => { presetRequests += 1; });
+        render(<SettingsPage />);
+        await userEvent.click(await screen.findByRole('button', { name: 'WLED' }));
+        expect(await screen.findByText('1 presets loaded')).toBeInTheDocument();
+        expect(document.getElementById('card-wled')).not.toBeNull();
+        expect(presetRequests).toBeGreaterThan(0);
+      });
+
+      it('hides WLED from navigation and search without printers:update', async () => {
+        signInWith(['settings:read']);
+        let presetRequests = 0;
+        serveWledPrinter(() => { presetRequests += 1; });
+        render(<SettingsPage />);
+        await screen.findByRole('heading', { name: 'Settings' });
+        expect(screen.queryByRole('button', { name: 'WLED' })).toBeNull();
+        await userEvent.type(screen.getByPlaceholderText(/Search settings/), 'wled');
+        expect(screen.queryByText('WLED')).toBeNull();
+        expect(document.getElementById('card-wled')).toBeNull();
+        expect(presetRequests).toBe(0);
+      });
+
+      it('redirects an unauthorized WLED deep link without mounting or loading presets', async () => {
+        signInWith(['settings:read']);
+        window.history.replaceState({}, '', '/settings?tab=wled');
+        let presetRequests = 0;
+        serveWledPrinter(() => { presetRequests += 1; });
+        render(<SettingsPage />);
+        await waitFor(() => expect(window.location.search).toBe(''));
+        expect(screen.getByRole('button', { name: 'General' })).toBeInTheDocument();
+        expect(screen.queryByRole('button', { name: 'WLED' })).toBeNull();
+        expect(document.getElementById('card-wled')).toBeNull();
+        expect(presetRequests).toBe(0);
+      });
+
+      it('unmounts WLED when printers:update is revoked during the session', async () => {
+        let permissions = ['settings:read', 'printers:update'];
+        signInWith(permissions);
+        server.use(http.get('/api/v1/auth/me', () => HttpResponse.json({
+          id: 2, username: 'viewer', role: 'user', is_active: true, is_admin: false,
+          groups: [], permissions, created_at: '2026-01-01T00:00:00Z',
+        })));
+        let presetRequests = 0;
+        serveWledPrinter(() => { presetRequests += 1; });
+        render(<SettingsPage />);
+        await userEvent.click(await screen.findByRole('button', { name: 'WLED' }));
+        expect(await screen.findByText('1 presets loaded')).toBeInTheDocument();
+        const requestsBeforeRevocation = presetRequests;
+
+        permissions = ['settings:read'];
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
+        await waitFor(() => expect(document.getElementById('card-wled')).toBeNull());
+        await waitFor(() => expect(window.location.search).toBe(''));
+        expect(screen.queryByRole('button', { name: 'WLED' })).toBeNull();
+        expect(presetRequests).toBe(requestsBeforeRevocation);
+      });
+    });
   });
 
   describe('external camera snapshot URL override (#1177)', () => {

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

@@ -433,6 +433,36 @@ export interface PrinterLocationUpdate {
   color?: string | null;
 }
 
+export interface WLEDPresets {
+  idle: number | null;
+  prepare: number | null;
+  printing: number | null;
+  paused: number | null;
+  finished: number | null;
+  error: number | null;
+  queue_waiting: number | null;
+  filament_problem: number | null;
+  hms_error: number | null;
+  offline: number | null;
+}
+
+export interface WLEDConfig {
+  enabled: boolean;
+  base_url: string | null;
+  presets: WLEDPresets;
+  finished_timeout_seconds: number | null;
+}
+
+export interface WLEDPreset {
+  id: number;
+  name: string;
+}
+
+export interface WLEDConnectionInfo {
+  name: string | null;
+  version: string | null;
+}
+
 export interface Printer {
   id: number;
   name: string;
@@ -460,6 +490,7 @@ export interface Printer {
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   wear_cost_per_hour: number | null;  // Wear cost per printing hour (#694)
+  wled_config: WLEDConfig | null;
   created_at: string;
   updated_at: string;
 }
@@ -785,6 +816,7 @@ export interface PrinterCreate {
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
   wear_cost_per_hour?: number | null;
+  wled_config?: WLEDConfig | null;
 }
 
 // Plate Detection
@@ -5368,6 +5400,21 @@ export const api = {
       method: 'PATCH',
       body: JSON.stringify(data),
     }),
+  getWledPresets: (id: number, baseUrl: string) =>
+    request<WLEDPreset[]>(`/printers/${id}/wled/presets`, {
+      method: 'POST',
+      body: JSON.stringify({ base_url: baseUrl }),
+    }),
+  testWledConnection: (id: number, baseUrl: string) =>
+    request<WLEDConnectionInfo>(`/printers/${id}/wled/test-connection`, {
+      method: 'POST',
+      body: JSON.stringify({ base_url: baseUrl }),
+    }),
+  testWledPreset: (id: number, baseUrl: string, presetId: number) =>
+    request<{ success: boolean }>(`/printers/${id}/wled/test-preset`, {
+      method: 'POST',
+      body: JSON.stringify({ base_url: baseUrl, preset_id: presetId }),
+    }),
   deletePrinter: (id: number, deleteArchives: boolean = true) =>
     request<{ status: string; archives_deleted: boolean }>(
       `/printers/${id}?delete_archives=${deleteArchives}`,

+ 391 - 0
frontend/src/components/WLEDSettings.tsx

@@ -0,0 +1,391 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { CheckCircle2, Circle, Lightbulb, Loader2, Play, RefreshCw, XCircle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api, ApiError } from '../api/client';
+import type { WLEDConfig, WLEDPreset, WLEDPresets } from '../api/client';
+import { Button } from './Button';
+import { Card, CardContent, CardHeader } from './Card';
+import { useToast } from '../contexts/ToastContext';
+import { registerSettingsSearch } from '../lib/settingsSearch';
+
+registerSettingsSearch({
+  labelKey: 'wled.title',
+  tab: 'wled',
+  keywords: 'wled led light preset printer status',
+  anchor: 'card-wled',
+});
+
+const emptyPresets: WLEDPresets = {
+  idle: null,
+  prepare: null,
+  printing: null,
+  paused: null,
+  finished: null,
+  error: null,
+  queue_waiting: null,
+  filament_problem: null,
+  hms_error: null,
+  offline: null,
+};
+
+const emptyConfig = (): WLEDConfig => ({
+  enabled: false,
+  base_url: null,
+  presets: { ...emptyPresets },
+  finished_timeout_seconds: 0,
+});
+
+const mappingKeys: (keyof WLEDPresets)[] = [
+  'idle',
+  'prepare',
+  'printing',
+  'paused',
+  'finished',
+  'error',
+  'queue_waiting',
+  'filament_problem',
+  'hms_error',
+  'offline',
+];
+
+type RequestKind = 'save' | 'presets' | 'connection' | 'presetTest';
+type RequestTicket = { printerId: number; url: string; context: number; sequence: number };
+
+export function WLEDSettings() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const { data: printers = [], isLoading } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters });
+  const [printerId, setPrinterId] = useState<number | null>(null);
+  const selectedPrinterId = useRef(printerId);
+  selectedPrinterId.current = printerId;
+  const contextGeneration = useRef(0);
+  const currentUrl = useRef('');
+  const requestSequence = useRef<Record<RequestKind, number>>({ save: 0, presets: 0, connection: 0, presetTest: 0 });
+  const nextRequest = useCallback((kind: RequestKind, url: string): RequestTicket => ({
+    printerId: selectedPrinterId.current!, url, context: contextGeneration.current,
+    sequence: ++requestSequence.current[kind],
+  }), []);
+  const isCurrentRequest = useCallback((kind: RequestKind, request: RequestTicket) =>
+    request.printerId === selectedPrinterId.current
+    && request.url === currentUrl.current
+    && request.context === contextGeneration.current
+    && request.sequence === requestSequence.current[kind], []);
+  useEffect(() => () => { contextGeneration.current += 1; }, []);
+  const initializedPrinterId = useRef<number | null>(null);
+  const [draftsByPrinterId, setDraftsByPrinterId] = useState<Record<number, WLEDConfig>>({});
+  const printer = useMemo(() => printers.find((item) => item.id === printerId), [printers, printerId]);
+  const config = printerId === null ? emptyConfig() : draftsByPrinterId[printerId] ?? printer?.wled_config ?? emptyConfig();
+  const currentConfig = useRef(config);
+  currentConfig.current = config;
+  if (printer && draftsByPrinterId[printer.id] === undefined) currentUrl.current = config.base_url?.trim() ?? '';
+  const setConfig = (change: (current: WLEDConfig) => WLEDConfig) => {
+    if (printerId === null) return;
+    setDraftsByPrinterId((drafts) => ({
+      ...drafts,
+      [printerId]: change(drafts[printerId] ?? config),
+    }));
+  };
+  const [presets, setPresets] = useState<WLEDPreset[]>([]);
+  const [presetState, setPresetState] = useState<'idle' | 'online' | 'offline'>('idle');
+  const [connection, setConnection] = useState<{ state: 'idle' | 'online' | 'offline'; name?: string | null; version?: string | null }>({ state: 'idle' });
+
+  useEffect(() => {
+    if (printerId === null ? !printers.length : printers.some((item) => item.id === printerId)) return;
+    contextGeneration.current += 1;
+    selectedPrinterId.current = printers[0]?.id ?? null;
+    setPrinterId(selectedPrinterId.current);
+  }, [printerId, printers]);
+
+  useEffect(() => {
+    if (!printer || initializedPrinterId.current === printer.id) return;
+    initializedPrinterId.current = printer.id;
+    currentUrl.current = config.base_url?.trim() ?? '';
+    setPresets([]);
+    setPresetState('idle');
+    setConnection({ state: 'idle' });
+  }, [printer, config.base_url]);
+
+  const saveMutation = useMutation({
+    mutationFn: (request: RequestTicket & { config: WLEDConfig }) =>
+      api.updatePrinter(request.printerId, { wled_config: request.config }),
+    onSuccess: (updated, request) => {
+      const isCurrent = isCurrentRequest('save', request);
+      const draftUnchanged = currentConfig.current === request.config;
+      if (request.sequence === requestSequence.current.save) {
+        queryClient.setQueryData<typeof printers>(['printers'], (old = []) =>
+          old.map((item) => (item.id === request.printerId ? updated : item)),
+        );
+        setDraftsByPrinterId((drafts) => {
+          if (drafts[request.printerId] !== request.config) return drafts;
+          const next = { ...drafts };
+          delete next[request.printerId];
+          return next;
+        });
+      }
+      if (isCurrent && draftUnchanged) showToast(t('wled.saved'), 'success');
+    },
+    onError: (error, request) => {
+      if (!isCurrentRequest('save', request) || currentConfig.current !== request.config) return;
+      showToast(error instanceof ApiError && error.status === 422 ? error.message : t('wled.saveError'), 'error');
+    },
+  });
+
+  const presetsMutation = useMutation({
+    mutationFn: (request: RequestTicket) => api.getWledPresets(request.printerId, request.url),
+    onSuccess: (loaded, request) => {
+      if (!isCurrentRequest('presets', request)) return;
+      setPresets(loaded);
+      setPresetState('online');
+    },
+    onError: (error, request) => {
+      if (!isCurrentRequest('presets', request)) return;
+      if (error instanceof ApiError && error.status === 422) {
+        showToast(error.message, 'error');
+        return;
+      }
+      setPresetState('offline');
+      showToast(t('wled.offlineHint'), 'error');
+    },
+  });
+
+  const loadPresets = presetsMutation.mutate;
+
+  useEffect(() => {
+    const savedConfig = printer?.wled_config;
+    if (savedConfig?.enabled && savedConfig.base_url && savedConfig.base_url === currentUrl.current) {
+      loadPresets(nextRequest('presets', savedConfig.base_url));
+    }
+  }, [printer, loadPresets, nextRequest]);
+
+  const connectionMutation = useMutation({
+    mutationFn: (request: RequestTicket) => api.testWledConnection(request.printerId, request.url),
+    onSuccess: (info, request) => {
+      if (isCurrentRequest('connection', request)) setConnection({ state: 'online', ...info });
+    },
+    onError: (error, request) => {
+      if (!isCurrentRequest('connection', request)) return;
+      if (error instanceof ApiError && error.status === 422) showToast(error.message, 'error');
+      else setConnection({ state: 'offline' });
+    },
+  });
+
+  const presetTestMutation = useMutation({
+    mutationFn: (request: RequestTicket & { presetId: number }) =>
+      api.testWledPreset(request.printerId, request.url, request.presetId),
+    onSuccess: (_result, request) => {
+      if (isCurrentRequest('presetTest', request)) showToast(t('wled.presetTested'), 'success');
+    },
+    onError: (error, request) => {
+      if (!isCurrentRequest('presetTest', request)) return;
+      showToast(error instanceof ApiError && error.status === 422 ? error.message : t('wled.presetTestFailed'), 'error');
+    },
+  });
+
+  const setPreset = (key: keyof WLEDPresets, value: string) => {
+    setConfig((current) => ({
+      ...current,
+      presets: { ...current.presets, [key]: value ? Number(value) : null },
+    }));
+  };
+
+  if (isLoading) return <p className="text-bambu-gray">{t('common.loading')}</p>;
+
+  const configuredCount = mappingKeys.filter((key) => config.presets[key] !== null).length;
+
+  return (
+    <div id="card-wled" className="max-w-7xl space-y-5">
+      <div>
+        <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+          <Lightbulb className="w-5 h-5 text-yellow-400" />
+          {t('wled.title')}
+        </h2>
+        <p className="mt-2 text-sm text-bambu-gray">{t('wled.description')}</p>
+      </div>
+
+      {!printers.length ? (
+        <Card>
+          <CardContent className="space-y-4">
+            <label className="flex items-center gap-3 text-white opacity-60">
+              <input type="checkbox" checked={false} disabled className="h-5 w-5 accent-bambu-green" />
+              {t('wled.enabled')}
+            </label>
+            <p className="text-sm text-bambu-gray">{t('wled.noPrinters')}</p>
+          </CardContent>
+        </Card>
+      ) : (
+        <>
+          <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-5 items-start">
+            <Card>
+              <CardHeader>
+                <h3 className="font-semibold text-white">{t('wled.connectionSection')}</h3>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <label className="block">
+                  <span className="block text-sm text-bambu-gray mb-1">{t('wled.printer')}</span>
+                  <select
+                    aria-label={t('wled.printer')}
+                    value={printerId ?? ''}
+                    onChange={(event) => {
+                      contextGeneration.current += 1;
+                      selectedPrinterId.current = Number(event.target.value);
+                      setPrinterId(selectedPrinterId.current);
+                    }}
+                    className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-3 py-2 text-white"
+                  >
+                    {printers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
+                  </select>
+                </label>
+
+                <label className="flex items-center gap-3 text-white">
+                  <input
+                    type="checkbox"
+                    checked={config.enabled}
+                    onChange={(event) => setConfig((current) => ({ ...current, enabled: event.target.checked }))}
+                    className="h-5 w-5 accent-bambu-green"
+                  />
+                  {t('wled.enabled')}
+                </label>
+
+                <fieldset
+                  disabled={!config.enabled}
+                  className={`space-y-3 transition-opacity ${config.enabled ? '' : 'opacity-60'}`}
+                >
+                  <label className="block">
+                    <span className="block text-sm text-bambu-gray mb-1">{t('wled.baseUrl')}</span>
+                    <input
+                      type="url"
+                      value={config.base_url ?? ''}
+                      onChange={(event) => {
+                        contextGeneration.current += 1;
+                        currentUrl.current = event.target.value.trim();
+                        setConfig((current) => ({ ...current, base_url: event.target.value }));
+                        setConnection({ state: 'idle' });
+                      }}
+                      placeholder="http://wled.local"
+                      className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-3 py-2 text-white"
+                    />
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    <Button
+                      variant="secondary"
+                      onClick={() => connectionMutation.mutate(nextRequest('connection', config.base_url?.trim() ?? ''))}
+                      disabled={!config.base_url?.trim() || connectionMutation.isPending}
+                    >
+                      {connectionMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle2 className="w-4 h-4" />}
+                      {t('wled.testConnection')}
+                    </Button>
+                    <Button
+                      variant="secondary"
+                      onClick={() => presetsMutation.mutate(nextRequest('presets', config.base_url?.trim() ?? ''))}
+                      disabled={!config.base_url?.trim() || presetsMutation.isPending}
+                    >
+                      {presetsMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
+                      {t('wled.loadPresets')}
+                    </Button>
+                  </div>
+
+                  <div aria-live="polite" className="flex items-center gap-2 text-sm">
+                    {connection.state === 'idle' && <><Circle className="w-3 h-3 text-bambu-gray" /><span className="text-bambu-gray">{t('wled.notTested')}</span></>}
+                    {connection.state === 'online' && <><CheckCircle2 className="w-4 h-4 text-green-400" /><span className="text-green-400">{t('wled.connected', { name: connection.name ? ` (${connection.name})` : '', version: connection.version ? ` · v${connection.version}` : '' })}</span></>}
+                    {connection.state === 'offline' && <><XCircle className="w-4 h-4 text-red-400" /><span className="text-red-400">{t('wled.notReachable')}</span></>}
+                  </div>
+
+                  <div aria-live="polite" className="text-sm">
+                    {presetState === 'online' && <span className="text-green-400">{t('wled.online', { count: presets.length })}</span>}
+                    {presetState === 'offline' && <span className="text-red-400">{t('wled.offlineHint')}</span>}
+                  </div>
+                </fieldset>
+              </CardContent>
+            </Card>
+
+            <Card>
+              <CardHeader>
+                <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
+                  <h3 className="font-semibold text-white">{t('wled.mappingSection')}</h3>
+                  <p className="text-xs text-bambu-gray">
+                    {t('wled.mappingSummary', { configured: configuredCount, disabled: mappingKeys.length - configuredCount })}
+                  </p>
+                </div>
+              </CardHeader>
+              <CardContent>
+                <p className="mb-4 text-xs text-bambu-gray">{t('wled.presetSegmentHint')}</p>
+                <fieldset
+                  disabled={!config.enabled}
+                  className={`space-y-5 transition-opacity ${config.enabled ? '' : 'opacity-60'}`}
+                >
+                  <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+                    {mappingKeys.map((key) => {
+                      const selected = config.presets[key];
+                      const missing = selected !== null && !presets.some((preset) => preset.id === selected);
+                      return (
+                        <div key={key}>
+                          <span className="block text-sm text-bambu-gray mb-1">{t(`wled.status.${key}`)}</span>
+                          <div className="flex gap-2">
+                            <select
+                              aria-label={t(`wled.status.${key}`)}
+                              value={selected ?? ''}
+                              onChange={(event) => setPreset(key, event.target.value)}
+                              className="min-w-0 flex-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-3 py-2 text-white"
+                            >
+                              <option value="">{t('wled.noPreset')}</option>
+                              {missing && <option value={selected!}>{t('wled.savedUnavailable', { id: selected })}</option>}
+                              {presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name} ({preset.id})</option>)}
+                            </select>
+                            <Button
+                              type="button"
+                              size="sm"
+                              variant="secondary"
+                              aria-label={`${t('wled.testPreset')} ${t(`wled.status.${key}`)}`}
+                              onClick={() => selected !== null && presetTestMutation.mutate({
+                                ...nextRequest('presetTest', config.base_url?.trim() ?? ''), presetId: selected,
+                              })}
+                              disabled={selected === null || presetTestMutation.isPending}
+                            >
+                              <Play className="w-3 h-3" />
+                              {t('wled.testPreset')}
+                            </Button>
+                          </div>
+                        </div>
+                      );
+                    })}
+                  </div>
+
+                  <label className="block max-w-xs">
+                    <span className="block text-sm text-bambu-gray mb-1">{t('wled.finishedTimeout')}</span>
+                    <input
+                      type="number"
+                      aria-label={t('wled.finishedTimeout')}
+                      min="0"
+                      max="86400"
+                      placeholder="0"
+                      value={config.finished_timeout_seconds ?? 0}
+                      onChange={(event) => setConfig((current) => ({
+                        ...current,
+                        finished_timeout_seconds: Number(event.target.value),
+                      }))}
+                      className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-3 py-2 text-white"
+                    />
+                    <span className="block text-xs text-bambu-gray mt-1">{t('wled.finishedTimeoutHint')}</span>
+                  </label>
+                </fieldset>
+              </CardContent>
+            </Card>
+          </div>
+
+          <div className="flex justify-end">
+            <Button
+              onClick={() => saveMutation.mutate({
+                ...nextRequest('save', config.base_url?.trim() ?? ''), config,
+              })}
+              disabled={saveMutation.isPending || (config.enabled && !config.base_url?.trim())}
+            >
+              {saveMutation.isPending ? t('common.saving') : t('common.save')}
+            </Button>
+          </div>
+        </>
+      )}
+    </div>
+  );
+}

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

@@ -2030,6 +2030,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Verbindung', mappingSection: 'Statuszuordnung', testConnection: 'Verbindung testen', notTested: 'Nicht getestet', connected: 'Mit WLED verbunden{{name}}{{version}}', notReachable: 'WLED ist nicht erreichbar.', testPreset: 'Testen', presetTested: 'Preset aktiviert', presetTestFailed: 'Preset konnte nicht aktiviert werden', mappingSummary: 'Konfigurierte Zustände: {{configured}} · Deaktivierte Zustände: {{disabled}}',
+    title: 'WLED', description: 'Farben, Effekte und Presets werden direkt in WLED verwaltet. Bambuddy ordnet Druckerzustände lediglich vorhandenen Presets zu.',
+    printer: 'Drucker', enabled: 'WLED-Integration aktivieren', baseUrl: 'WLED-URL', loadPresets: 'Presets laden', online: '{{count}} Presets geladen', offlineHint: 'WLED ist nicht erreichbar. Gespeicherte Preset-IDs bleiben erhalten.', noPrinters: 'Keine Drucker konfiguriert.', noPreset: 'Deaktiviert – kein Preset', savedUnavailable: 'Gespeichertes Preset {{id}} – derzeit nicht verfügbar', finishedTimeout: 'Finished-Timeout (Sekunden)', finishedTimeoutHint: 'Danach vom Finished-Preset zum Idle-Preset wechseln. 0 deaktiviert den Timeout.', saved: 'WLED-Einstellungen gespeichert', saveError: 'WLED-Einstellungen konnten nicht gespeichert werden',
+    presetSegmentHint: 'Wähle das Zielsegment beim Erstellen des Presets direkt in WLED aus und aktiviere „Checked segments only“. Zur Laufzeit aktiviert Bambuddy ausschließlich das konfigurierte Preset.',
+    status: { idle: 'Bereit', prepare: 'Vorbereitung', printing: 'Druckt', paused: 'Pausiert', finished: 'Fertig', error: 'Fehlgeschlagen / Fehler', queue_waiting: 'Wartet auf freie Druckplatte', filament_problem: 'Filamentproblem', hms_error: 'HMS-Fehler', offline: 'Offline' },
+  },
+
   settings: {
     sliceEngine: 'Slice-Engine',
     sliceEngineSidecar: 'Server-Sidecar',
@@ -2050,6 +2058,7 @@ export default {
       filament: 'Filament',
       camera: 'Kamera',
       network: 'Netzwerk',
+      wled: 'WLED',
       apiKeys: 'API-Schlüssel',
       virtualPrinter: 'Virtueller Drucker',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2050,6 +2050,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Connection', mappingSection: 'Status mapping', testConnection: 'Test connection', notTested: 'Not tested', connected: 'Connected to WLED{{name}}{{version}}', notReachable: 'Unable to connect to WLED.', testPreset: 'Test', presetTested: 'Preset activated', presetTestFailed: 'Could not activate preset', mappingSummary: 'Configured states: {{configured}} · Disabled states: {{disabled}}',
+    title: 'WLED', description: 'Colours, effects and presets are managed directly in WLED. Bambuddy only maps printer states to existing presets.',
+    printer: 'Printer', enabled: 'Enable WLED integration', baseUrl: 'WLED URL', loadPresets: 'Load presets', online: '{{count}} presets loaded', offlineHint: 'WLED is unavailable. Saved preset IDs are kept.', noPrinters: 'No printers configured.', noPreset: 'Disabled — no preset', savedUnavailable: 'Saved preset {{id}} — currently unavailable', finishedTimeout: 'Finished timeout (seconds)', finishedTimeoutHint: 'After this time, switch from Finished to the Idle preset. Set 0 to disable.', saved: 'WLED settings saved', saveError: 'Could not save WLED settings',
+    presetSegmentHint: 'Select the target segment when creating the preset in WLED and enable “Checked segments only”. During runtime, Bambuddy only activates the configured preset.',
+    status: { idle: 'Idle', prepare: 'Preparing', printing: 'Printing', paused: 'Paused', finished: 'Finished', error: 'Failed / Error', queue_waiting: 'Awaiting plate clear', filament_problem: 'Filament problem', hms_error: 'HMS error', offline: 'Offline' },
+  },
+
   settings: {
     sliceEngine: 'Slice engine',
     sliceEngineSidecar: 'Server sidecar',
@@ -2070,6 +2078,7 @@ export default {
       filament: 'Filament',
       camera: 'Camera',
       network: 'Network',
+      wled: 'WLED',
       apiKeys: 'API Keys',
       virtualPrinter: 'Virtual Printer',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2031,6 +2031,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Conexión', mappingSection: 'Asignación de estados', testConnection: 'Probar conexión', notTested: 'Sin probar', connected: 'Conectado a WLED{{name}}{{version}}', notReachable: 'No se puede conectar con WLED.', testPreset: 'Probar', presetTested: 'Preajuste activado', presetTestFailed: 'No se pudo activar el preajuste', mappingSummary: 'Estados configurados: {{configured}} · Estados desactivados: {{disabled}}',
+    title: 'WLED', description: 'Los colores, efectos y preajustes se gestionan directamente en WLED. Bambuddy solo asigna estados de impresora a preajustes existentes.',
+    printer: 'Impresora', enabled: 'Activar integración WLED', baseUrl: 'URL de WLED', loadPresets: 'Cargar preajustes', online: '{{count}} preajustes cargados', offlineHint: 'WLED no está disponible. Se conservan los ID guardados.', noPrinters: 'No hay impresoras configuradas.', noPreset: 'Desactivado — sin preajuste', savedUnavailable: 'Preajuste {{id}} guardado — no disponible', finishedTimeout: 'Tiempo tras finalizar (segundos)', finishedTimeoutHint: 'Después cambia del preajuste Finalizado al de Inactivo. 0 lo desactiva.', saved: 'Ajustes de WLED guardados', saveError: 'No se pudieron guardar los ajustes de WLED',
+    presetSegmentHint: 'Selecciona el segmento de destino al crear el preajuste en WLED y activa “Checked segments only”. Durante el funcionamiento, Bambuddy solo activa el preajuste configurado.',
+    status: { idle: 'Inactiva', prepare: 'Preparando', printing: 'Imprimiendo', paused: 'Pausada', finished: 'Finalizada', error: 'Fallo / Error', queue_waiting: 'Esperando retirar la placa', filament_problem: 'Problema de filamento', hms_error: 'Error HMS', offline: 'Sin conexión' },
+  },
+
   settings: {
     sliceEngine: 'Motor de laminado',
     sliceEngineSidecar: 'Sidecar del servidor',
@@ -2051,6 +2059,7 @@ export default {
       filament: 'Filamento',
       camera: 'Cámara',
       network: 'Red',
+      wled: 'WLED',
       apiKeys: 'Claves API',
       virtualPrinter: 'Impresora virtual',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2029,6 +2029,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Connexion', mappingSection: 'Association des états', testConnection: 'Tester la connexion', notTested: 'Non testé', connected: 'Connecté à WLED{{name}}{{version}}', notReachable: 'Impossible de se connecter à WLED.', testPreset: 'Tester', presetTested: 'Préréglage activé', presetTestFailed: 'Impossible d’activer le préréglage', mappingSummary: 'États configurés : {{configured}} · États désactivés : {{disabled}}',
+    title: 'WLED', description: 'Les couleurs, effets et préréglages sont gérés directement dans WLED. Bambuddy associe uniquement les états de l’imprimante aux préréglages existants.',
+    printer: 'Imprimante', enabled: 'Activer l’intégration WLED', baseUrl: 'URL WLED', loadPresets: 'Charger les préréglages', online: '{{count}} préréglages chargés', offlineHint: 'WLED est indisponible. Les ID enregistrés sont conservés.', noPrinters: 'Aucune imprimante configurée.', noPreset: 'Désactivé — aucun préréglage', savedUnavailable: 'Préréglage {{id}} enregistré — indisponible', finishedTimeout: 'Délai après fin (secondes)', finishedTimeoutHint: 'Passe ensuite du préréglage Terminé à Inactif. 0 désactive le délai.', saved: 'Paramètres WLED enregistrés', saveError: 'Impossible d’enregistrer les paramètres WLED',
+    presetSegmentHint: 'Sélectionnez le segment cible lors de la création du préréglage dans WLED et activez « Checked segments only ». À l’exécution, Bambuddy active uniquement le préréglage configuré.',
+    status: { idle: 'Inactive', prepare: 'Préparation', printing: 'Impression', paused: 'En pause', finished: 'Terminée', error: 'Échec / Erreur', queue_waiting: 'En attente du retrait du plateau', filament_problem: 'Problème de filament', hms_error: 'Erreur HMS', offline: 'Hors ligne' },
+  },
+
   settings: {
     sliceEngine: 'Moteur de découpage',
     sliceEngineSidecar: 'Sidecar serveur',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'Filament',
       camera: 'Caméra',
       network: 'Réseau',
+      wled: 'WLED',
       apiKeys: 'Clés API',
       virtualPrinter: 'Imprimante virtuelle',
       failureDetection: 'Détection d\'échec',

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

@@ -2029,6 +2029,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Connessione', mappingSection: 'Mappatura stati', testConnection: 'Testa connessione', notTested: 'Non testata', connected: 'Connesso a WLED{{name}}{{version}}', notReachable: 'Impossibile connettersi a WLED.', testPreset: 'Test', presetTested: 'Preset attivato', presetTestFailed: 'Impossibile attivare il preset', mappingSummary: 'Stati configurati: {{configured}} · Stati disattivati: {{disabled}}',
+    title: 'WLED', description: 'Colori, effetti e preset vengono gestiti direttamente in WLED. Bambuddy associa solo gli stati della stampante ai preset esistenti.',
+    printer: 'Stampante', enabled: 'Attiva integrazione WLED', baseUrl: 'URL WLED', loadPresets: 'Carica preset', online: '{{count}} preset caricati', offlineHint: 'WLED non è raggiungibile. Gli ID salvati vengono mantenuti.', noPrinters: 'Nessuna stampante configurata.', noPreset: 'Disattivato — nessun preset', savedUnavailable: 'Preset {{id}} salvato — non disponibile', finishedTimeout: 'Timeout fine (secondi)', finishedTimeoutHint: 'Poi passa dal preset Fine al preset Inattiva. 0 disattiva il timeout.', saved: 'Impostazioni WLED salvate', saveError: 'Impossibile salvare le impostazioni WLED',
+    presetSegmentHint: 'Seleziona il segmento di destinazione quando crei il preset in WLED e attiva “Checked segments only”. Durante l’esecuzione, Bambuddy attiva solo il preset configurato.',
+    status: { idle: 'Inattiva', prepare: 'Preparazione', printing: 'Stampa', paused: 'In pausa', finished: 'Terminata', error: 'Fallita / Errore', queue_waiting: 'In attesa di liberare il piano', filament_problem: 'Problema filamento', hms_error: 'Errore HMS', offline: 'Offline' },
+  },
+
   settings: {
     sliceEngine: 'Motore di slicing',
     sliceEngineSidecar: 'Sidecar del server',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'Filamento',
       camera: 'Telecamera',
       network: 'Rete',
+      wled: 'WLED',
       apiKeys: 'Chiavi API',
       virtualPrinter: 'Stampante virtuale',
       failureDetection: 'Rilevamento guasti',

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

@@ -2029,6 +2029,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: '接続', mappingSection: 'ステータスマッピング', testConnection: '接続をテスト', notTested: '未テスト', connected: 'WLED に接続済み{{name}}{{version}}', notReachable: 'WLED に接続できません。', testPreset: 'テスト', presetTested: 'プリセットを有効化しました', presetTestFailed: 'プリセットを有効化できませんでした', mappingSummary: '設定済みの状態: {{configured}}・無効な状態: {{disabled}}',
+    title: 'WLED', description: '色、エフェクト、プリセットはWLEDで直接管理します。Bambuddyはプリンターの状態を既存のプリセットに割り当てるだけです。',
+    printer: 'プリンター', enabled: 'WLED連携を有効化', baseUrl: 'WLEDのアドレス', loadPresets: 'プリセットを読み込む', online: '{{count}}件のプリセットを読み込みました', offlineHint: 'WLEDに接続できません。保存済みのプリセットIDは保持されます。', noPrinters: 'プリンターが設定されていません。', noPreset: '無効 — プリセットなし', savedUnavailable: '保存済みプリセット {{id}} — 現在利用不可', finishedTimeout: '完了タイムアウト(秒)', finishedTimeoutHint: 'この時間後に完了から待機プリセットへ切り替えます。0で無効です。', saved: 'WLED設定を保存しました', saveError: 'WLED設定を保存できませんでした',
+    presetSegmentHint: 'WLEDでプリセットを作成するときに対象セグメントを選択し、「Checked segments only」を有効にしてください。実行時、Bambuddyは設定されたプリセットのみを有効化します。',
+    status: { idle: '待機', prepare: '準備中', printing: '印刷中', paused: '一時停止', finished: '完了', error: '失敗 / エラー', queue_waiting: 'プレート取り外し待ち', filament_problem: 'フィラメント問題', hms_error: 'HMSエラー', offline: 'オフライン' },
+  },
+
   settings: {
     sliceEngine: 'スライスエンジン',
     sliceEngineSidecar: 'サーバーサイドカー',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'フィラメント',
       camera: 'カメラ',
       network: 'ネットワーク',
+      wled: 'WLED',
       apiKeys: 'APIキー',
       virtualPrinter: '仮想プリンター',
       spoolbuddy: 'SpoolBuddy',

+ 9 - 0
frontend/src/i18n/locales/ko.ts

@@ -1943,6 +1943,14 @@ export default {
     noPermissionEditIntervals: '간격을 편집할 권한이 없습니다',
     configureSettings: '유지보수 유형 및 간격 설정'
   },
+  wled: {
+    connectionSection: '연결', mappingSection: '상태 매핑', testConnection: '연결 테스트', notTested: '테스트하지 않음', connected: 'WLED에 연결됨{{name}}{{version}}', notReachable: 'WLED에 연결할 수 없습니다.', testPreset: '테스트', presetTested: '프리셋이 활성화되었습니다', presetTestFailed: '프리셋을 활성화할 수 없습니다', mappingSummary: '설정된 상태: {{configured}} · 비활성 상태: {{disabled}}',
+    title: 'WLED', description: '색상, 효과 및 프리셋은 WLED에서 직접 관리합니다. Bambuddy는 프린터 상태를 기존 프리셋에 연결만 합니다.',
+    printer: '프린터', enabled: 'WLED 연동 활성화', baseUrl: 'WLED 주소', loadPresets: '프리셋 불러오기', online: '프리셋 {{count}}개를 불러왔습니다', offlineHint: 'WLED에 연결할 수 없습니다. 저장된 프리셋 ID는 유지됩니다.', noPrinters: '설정된 프린터가 없습니다.', noPreset: '비활성화 — 프리셋 없음', savedUnavailable: '저장된 프리셋 {{id}} — 현재 사용 불가', finishedTimeout: '완료 타임아웃(초)', finishedTimeoutHint: '이 시간이 지나면 완료에서 유휴 프리셋으로 전환합니다. 0은 비활성화입니다.', saved: 'WLED 설정을 저장했습니다', saveError: 'WLED 설정을 저장할 수 없습니다',
+    presetSegmentHint: 'WLED에서 프리셋을 만들 때 대상 세그먼트를 선택하고 “Checked segments only”를 활성화하세요. 실행 중에는 Bambuddy가 설정된 프리셋만 활성화합니다.',
+    status: { idle: '유휴', prepare: '준비 중', printing: '인쇄 중', paused: '일시 중지', finished: '완료', error: '실패 / 오류', queue_waiting: '플레이트 제거 대기', filament_problem: '필라멘트 문제', hms_error: 'HMS 오류', offline: '오프라인' },
+  },
+
   settings: {
     sliceEngine: '슬라이스 엔진',
     sliceEngineSidecar: '서버 사이드카',
@@ -1962,6 +1970,7 @@ export default {
       filament: '필라멘트',
       camera: '카메라',
       network: '네트워크',
+      wled: 'WLED',
       apiKeys: 'API 키',
       virtualPrinter: '가상 프린터',
       spoolbuddy: 'SpoolBuddy',

+ 9 - 0
frontend/src/i18n/locales/nl.ts

@@ -2048,6 +2048,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Verbinding', mappingSection: 'Statustoewijzing', testConnection: 'Verbinding testen', notTested: 'Niet getest', connected: 'Verbonden met WLED{{name}}{{version}}', notReachable: 'Kan geen verbinding maken met WLED.', testPreset: 'Testen', presetTested: 'Preset geactiveerd', presetTestFailed: 'Preset kon niet worden geactiveerd', mappingSummary: 'Geconfigureerde statussen: {{configured}} · Uitgeschakelde statussen: {{disabled}}',
+    title: 'WLED', description: 'Kleuren, effecten en presets worden rechtstreeks in WLED beheerd. Bambuddy koppelt alleen printerstatussen aan bestaande presets.',
+    printer: 'Printer', enabled: 'WLED-integratie inschakelen', baseUrl: 'WLED-URL', loadPresets: 'Presets laden', online: '{{count}} presets geladen', offlineHint: 'WLED is niet bereikbaar. Opgeslagen preset-ID’s blijven behouden.', noPrinters: 'Geen printers geconfigureerd.', noPreset: 'Uitgeschakeld — geen preset', savedUnavailable: 'Opgeslagen preset {{id}} — niet beschikbaar', finishedTimeout: 'Time-out na voltooiing (seconden)', finishedTimeoutHint: 'Schakel daarna van Voltooid naar de Inactief-preset. 0 schakelt dit uit.', saved: 'WLED-instellingen opgeslagen', saveError: 'WLED-instellingen konden niet worden opgeslagen',
+    presetSegmentHint: 'Selecteer het doelsegment wanneer je de preset in WLED maakt en schakel “Checked segments only” in. Tijdens gebruik activeert Bambuddy alleen de geconfigureerde preset.',
+    status: { idle: 'Inactief', prepare: 'Voorbereiden', printing: 'Afdrukken', paused: 'Gepauzeerd', finished: 'Voltooid', error: 'Mislukt / Fout', queue_waiting: 'Wachten op lege plaat', filament_problem: 'Filamentprobleem', hms_error: 'HMS-fout', offline: 'Offline' },
+  },
+
   settings: {
     sliceEngine: 'Slice-engine',
     sliceEngineSidecar: 'Server-sidecar',
@@ -2068,6 +2076,7 @@ export default {
       filament: 'Filament',
       camera: 'Camera',
       network: 'Netwerk',
+      wled: 'WLED',
       apiKeys: 'API-sleutels',
       virtualPrinter: 'Virtuele printer',
       spoolbuddy: 'SpoolBuddy',

+ 9 - 0
frontend/src/i18n/locales/pl.ts

@@ -2050,6 +2050,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Połączenie', mappingSection: 'Przypisanie stanów', testConnection: 'Testuj połączenie', notTested: 'Nie testowano', connected: 'Połączono z WLED{{name}}{{version}}', notReachable: 'Nie można połączyć się z WLED.', testPreset: 'Testuj', presetTested: 'Preset aktywowany', presetTestFailed: 'Nie udało się aktywować presetu', mappingSummary: 'Skonfigurowane stany: {{configured}} · Wyłączone stany: {{disabled}}',
+    title: 'WLED', description: 'Kolory, efekty i presety są zarządzane bezpośrednio w WLED. Bambuddy jedynie przypisuje stany drukarki do istniejących presetów.',
+    printer: 'Drukarka', enabled: 'Włącz integrację WLED', baseUrl: 'Adres URL WLED', loadPresets: 'Wczytaj presety', online: 'Wczytano presety: {{count}}', offlineHint: 'WLED jest niedostępny. Zapisane identyfikatory presetów zostają zachowane.', noPrinters: 'Brak skonfigurowanych drukarek.', noPreset: 'Wyłączone — brak presetu', savedUnavailable: 'Zapisany preset {{id}} — obecnie niedostępny', finishedTimeout: 'Czas po zakończeniu (sekundy)', finishedTimeoutHint: 'Po tym czasie przełącz z presetu Zakończono na preset Bezczynna. 0 wyłącza tę funkcję.', saved: 'Zapisano ustawienia WLED', saveError: 'Nie udało się zapisać ustawień WLED',
+    presetSegmentHint: 'Podczas tworzenia presetu w WLED wybierz docelowy segment i włącz „Checked segments only”. W trakcie działania Bambuddy aktywuje wyłącznie skonfigurowany preset.',
+    status: { idle: 'Bezczynna', prepare: 'Przygotowanie', printing: 'Drukowanie', paused: 'Wstrzymano', finished: 'Zakończono', error: 'Niepowodzenie / błąd', queue_waiting: 'Oczekiwanie na opróżnienie płyty', filament_problem: 'Problem z filamentem', hms_error: 'Błąd HMS', offline: 'Offline' },
+  },
+
   settings: {
     sliceEngine: 'Silnik slicowania',
     sliceEngineSidecar: 'Sidecar na serwerze',
@@ -2070,6 +2078,7 @@ export default {
       filament: 'Filament',
       camera: 'Kamera',
       network: 'Sieć',
+      wled: 'WLED',
       apiKeys: 'Klucze API',
       virtualPrinter: 'Wirtualna drukarka',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2029,6 +2029,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Conexão', mappingSection: 'Mapeamento de status', testConnection: 'Testar conexão', notTested: 'Não testado', connected: 'Conectado ao WLED{{name}}{{version}}', notReachable: 'Não foi possível conectar ao WLED.', testPreset: 'Testar', presetTested: 'Predefinição ativada', presetTestFailed: 'Não foi possível ativar a predefinição', mappingSummary: 'Estados configurados: {{configured}} · Estados desativados: {{disabled}}',
+    title: 'WLED', description: 'Cores, efeitos e predefinições são gerenciados diretamente no WLED. O Bambuddy apenas associa estados da impressora a predefinições existentes.',
+    printer: 'Impressora', enabled: 'Ativar integração WLED', baseUrl: 'URL do WLED', loadPresets: 'Carregar predefinições', online: '{{count}} predefinições carregadas', offlineHint: 'WLED indisponível. Os IDs salvos serão mantidos.', noPrinters: 'Nenhuma impressora configurada.', noPreset: 'Desativado — sem predefinição', savedUnavailable: 'Predefinição {{id}} salva — indisponível', finishedTimeout: 'Tempo após conclusão (segundos)', finishedTimeoutHint: 'Depois muda da predefinição Concluída para Ociosa. 0 desativa.', saved: 'Configurações WLED salvas', saveError: 'Não foi possível salvar as configurações WLED',
+    presetSegmentHint: 'Selecione o segmento de destino ao criar a predefinição no WLED e ative “Checked segments only”. Durante a execução, o Bambuddy ativa apenas a predefinição configurada.',
+    status: { idle: 'Ociosa', prepare: 'Preparando', printing: 'Imprimindo', paused: 'Pausada', finished: 'Concluída', error: 'Falha / Erro', queue_waiting: 'Aguardando liberar a mesa', filament_problem: 'Problema de filamento', hms_error: 'Erro HMS', offline: 'Offline' },
+  },
+
   settings: {
     sliceEngine: 'Motor de fatiamento',
     sliceEngineSidecar: 'Sidecar do servidor',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'Filamento',
       camera: 'Câmera',
       network: 'Rede',
+      wled: 'WLED',
       apiKeys: 'Chaves API',
       virtualPrinter: 'Impressora Virtual',
       failureDetection: 'Detecção de Falhas',

+ 9 - 0
frontend/src/i18n/locales/ru.ts

@@ -1940,6 +1940,14 @@ export default {
     noPermissionEditIntervals: "У вас нет разрешения изменять интервалы",
     configureSettings: "Настроить виды обслуживания и интервалы",
   },
+  wled: {
+    connectionSection: 'Подключение', mappingSection: 'Сопоставление состояний', testConnection: 'Проверить подключение', notTested: 'Не проверено', connected: 'Подключено к WLED{{name}}{{version}}', notReachable: 'Не удалось подключиться к WLED.', testPreset: 'Тест', presetTested: 'Пресет активирован', presetTestFailed: 'Не удалось активировать пресет', mappingSummary: 'Настроено состояний: {{configured}} · Отключено состояний: {{disabled}}',
+    title: "WLED", description: "Цвета, эффекты и пресеты управляются непосредственно в WLED. Bambuddy только сопоставляет состояния принтера с существующими пресетами.",
+    printer: "Принтер", enabled: "Включить интеграцию WLED", baseUrl: "URL WLED", loadPresets: "Загрузить пресеты", online: "Загружено пресетов: {{count}}", offlineHint: "WLED недоступен. Сохранённые ID пресетов останутся.", noPrinters: "Принтеры не настроены.", noPreset: "Отключено — без пресета", savedUnavailable: "Сохранённый пресет {{id}} — недоступен", finishedTimeout: "Тайм-аут завершения (секунды)", finishedTimeoutHint: "Затем переключить пресет Завершено на Ожидание. 0 отключает тайм-аут.", saved: "Настройки WLED сохранены", saveError: "Не удалось сохранить настройки WLED",
+    presetSegmentHint: "При создании пресета в WLED выберите целевой сегмент и включите «Checked segments only». Во время работы Bambuddy только активирует настроенный пресет.",
+    status: { idle: "Ожидание", prepare: "Подготовка", printing: "Печать", paused: "Пауза", finished: "Завершено", error: "Сбой / Ошибка", queue_waiting: "Ожидание освобождения стола", filament_problem: "Проблема с филаментом", hms_error: "Ошибка HMS", offline: "Не в сети" },
+  },
+
   settings: {
     sliceEngine: 'Движок нарезки',
     sliceEngineSidecar: 'Серверный sidecar',
@@ -1959,6 +1967,7 @@ export default {
       filament: "Филамент",
       camera: 'Камера',
       network: "Сеть",
+      wled: "WLED",
       apiKeys: "Ключи API",
       virtualPrinter: "Виртуальный принтер",
       spoolbuddy: "SpoolBuddy",

+ 42 - 0
frontend/src/i18n/locales/sv.ts

@@ -2047,6 +2047,47 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Anslutning',
+    mappingSection: 'Statuskoppling',
+    testConnection: 'Testa anslutningen',
+    notTested: 'Inte testad',
+    connected: 'Ansluten till WLED{{name}}{{version}}',
+    notReachable: 'Det gick inte att ansluta till WLED.',
+    testPreset: 'Testa',
+    presetTested: 'Förinställningen har aktiverats',
+    presetTestFailed: 'Det gick inte att aktivera förinställningen',
+    mappingSummary: 'Konfigurerade tillstånd: {{configured}} · Inaktiverade tillstånd: {{disabled}}',
+    title: 'WLED',
+    description: 'Färger, effekter och förinställningar hanteras direkt i WLED. Bambuddy kopplar endast skrivarens tillstånd till befintliga förinställningar.',
+    printer: 'Skrivare',
+    enabled: 'Aktivera WLED-integrationen',
+    baseUrl: 'WLED-adress',
+    loadPresets: 'Läs in förinställningar',
+    online: '{{count}} förinställningar har lästs in',
+    offlineHint: 'WLED är inte tillgängligt. Sparade förinställnings-ID:n behålls.',
+    noPrinters: 'Inga skrivare har konfigurerats.',
+    noPreset: 'Inaktiverad — ingen förinställning',
+    savedUnavailable: 'Sparad förinställning {{id}} — inte tillgänglig just nu',
+    finishedTimeout: 'Tidsgräns efter slutförd utskrift (sekunder)',
+    finishedTimeoutHint: 'Efter denna tid växlar förinställningen från Slutförd till Viloläge. Ange 0 för att inaktivera tidsgränsen.',
+    saved: 'WLED-inställningarna har sparats',
+    saveError: 'Det gick inte att spara WLED-inställningarna',
+    presetSegmentHint: 'Välj målsegmentet när du skapar förinställningen i WLED och aktivera alternativet för att endast inkludera markerade segment. Under drift aktiverar Bambuddy endast den konfigurerade förinställningen.',
+    status: {
+      idle: 'Viloläge',
+      prepare: 'Förbereder',
+      printing: 'Skriver ut',
+      paused: 'Pausad',
+      finished: 'Slutförd',
+      error: 'Misslyckad / Fel',
+      queue_waiting: 'Väntar på att byggplattan ska tömmas',
+      filament_problem: 'Filamentproblem',
+      hms_error: 'HMS-fel',
+      offline: 'Frånkopplad',
+    },
+  },
+
   settings: {
     sliceEngine: 'Slicer-motor',
     sliceEngineSidecar: 'Server-sidecar',
@@ -2067,6 +2108,7 @@ export default {
       filament: 'Filament',
       camera: 'Kamera',
       network: 'Nätverk',
+      wled: 'WLED',
       apiKeys: 'API-nycklar',
       virtualPrinter: 'Virtuell skrivare',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2031,6 +2031,14 @@ export default {
   },
 
   // Ayarlar sayfası
+  wled: {
+    connectionSection: 'Bağlantı', mappingSection: 'Durum eşleme', testConnection: 'Bağlantıyı test et', notTested: 'Test edilmedi', connected: 'WLED\'e bağlandı{{name}}{{version}}', notReachable: 'WLED\'e bağlanılamıyor.', testPreset: 'Test', presetTested: 'Ön ayar etkinleştirildi', presetTestFailed: 'Ön ayar etkinleştirilemedi', mappingSummary: 'Yapılandırılmış durumlar: {{configured}} · Devre dışı durumlar: {{disabled}}',
+    title: 'WLED', description: 'Renkler, efektler ve ön ayarlar doğrudan WLED içinde yönetilir. Bambuddy yalnızca yazıcı durumlarını mevcut ön ayarlarla eşleştirir.',
+    printer: 'Yazıcı', enabled: 'WLED entegrasyonunu etkinleştir', baseUrl: 'WLED URL’si', loadPresets: 'Ön ayarları yükle', online: '{{count}} ön ayar yüklendi', offlineHint: 'WLED kullanılamıyor. Kayıtlı ön ayar kimlikleri korunur.', noPrinters: 'Yapılandırılmış yazıcı yok.', noPreset: 'Devre dışı — ön ayar yok', savedUnavailable: 'Kayıtlı ön ayar {{id}} — kullanılamıyor', finishedTimeout: 'Bitiş zaman aşımı (saniye)', finishedTimeoutHint: 'Bu süreden sonra Bitti ön ayarından Boşta ön ayarına geçer. 0 devre dışı bırakır.', saved: 'WLED ayarları kaydedildi', saveError: 'WLED ayarları kaydedilemedi',
+    presetSegmentHint: 'Ön ayarı WLED içinde oluştururken hedef segmenti seçin ve “Checked segments only” seçeneğini etkinleştirin. Çalışma sırasında Bambuddy yalnızca yapılandırılmış ön ayarı etkinleştirir.',
+    status: { idle: 'Boşta', prepare: 'Hazırlanıyor', printing: 'Yazdırılıyor', paused: 'Duraklatıldı', finished: 'Bitti', error: 'Başarısız / Hata', queue_waiting: 'Tablanın boşaltılması bekleniyor', filament_problem: 'Filament sorunu', hms_error: 'HMS hatası', offline: 'Çevrimdışı' },
+  },
+
   settings: {
     sliceEngine: 'Dilimleme motoru',
     sliceEngineSidecar: 'Sunucu sidecar',
@@ -2051,6 +2059,7 @@ export default {
       filament: 'Filament',
       camera: 'Kamera',
       network: 'Ağ',
+      wled: 'WLED',
       apiKeys: 'API Anahtarları',
       virtualPrinter: 'Sanal Yazıcı',
       spoolbuddy: 'SpoolBuddy',

+ 9 - 0
frontend/src/i18n/locales/uk.ts

@@ -2046,6 +2046,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: 'Підключення', mappingSection: 'Зіставлення станів', testConnection: 'Перевірити підключення', notTested: 'Не перевірено', connected: 'Підключено до WLED{{name}}{{version}}', notReachable: 'Не вдалося підключитися до WLED.', testPreset: 'Тест', presetTested: 'Пресет активовано', presetTestFailed: 'Не вдалося активувати пресет', mappingSummary: 'Налаштовано станів: {{configured}} · Вимкнено станів: {{disabled}}',
+    title: "WLED", description: "Кольори, ефекти й пресети керуються безпосередньо у WLED. Bambuddy лише зіставляє стани принтера з наявними пресетами.",
+    printer: "Принтер", enabled: "Увімкнути інтеграцію WLED", baseUrl: "URL WLED", loadPresets: "Завантажити пресети", online: "Завантажено пресетів: {{count}}", offlineHint: "WLED недоступний. Збережені ID пресетів залишаться.", noPrinters: "Принтери не налаштовані.", noPreset: "Вимкнено — без пресету", savedUnavailable: "Збережений пресет {{id}} — недоступний", finishedTimeout: "Тайм-аут завершення (секунди)", finishedTimeoutHint: "Після цього перейти з пресету Завершено до Очікування. 0 вимикає тайм-аут.", saved: "Налаштування WLED збережено", saveError: "Не вдалося зберегти налаштування WLED",
+    presetSegmentHint: "Під час створення пресету в WLED виберіть цільовий сегмент і ввімкніть «Checked segments only». Під час роботи Bambuddy лише активує налаштований пресет.",
+    status: { idle: "Очікування", prepare: "Підготовка", printing: "Друк", paused: "Призупинено", finished: "Завершено", error: "Збій / Помилка", queue_waiting: "Очікування звільнення платформи", filament_problem: "Проблема з філаментом", hms_error: "Помилка HMS", offline: "Не в мережі" },
+  },
+
   settings: {
     sliceEngine: 'Рушій нарізання',
     sliceEngineSidecar: 'Серверний sidecar',
@@ -2066,6 +2074,7 @@ export default {
       filament: "Філамент",
       camera: 'Камера',
       network: "Мережа",
+      wled: "WLED",
       apiKeys: "Ключі API",
       virtualPrinter: "Віртуальний принтер",
       spoolbuddy: "SpoolBuddy",

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

@@ -2029,6 +2029,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: '连接', mappingSection: '状态映射', testConnection: '测试连接', notTested: '未测试', connected: '已连接到 WLED{{name}}{{version}}', notReachable: '无法连接到 WLED。', testPreset: '测试', presetTested: '预设已激活', presetTestFailed: '无法激活预设', mappingSummary: '已配置状态: {{configured}} · 已禁用状态: {{disabled}}',
+    title: 'WLED', description: '颜色、效果和预设均直接在 WLED 中管理。Bambuddy 仅将打印机状态映射到现有预设。',
+    printer: '打印机', enabled: '启用 WLED 集成', baseUrl: 'WLED 地址', loadPresets: '加载预设', online: '已加载 {{count}} 个预设', offlineHint: 'WLED 不可用。已保存的预设 ID 将保留。', noPrinters: '尚未配置打印机。', noPreset: '已禁用 — 无预设', savedUnavailable: '已保存预设 {{id}} — 当前不可用', finishedTimeout: '完成超时(秒)', finishedTimeoutHint: '到时后从完成预设切换到空闲预设。设为 0 可禁用。', saved: 'WLED 设置已保存', saveError: '无法保存 WLED 设置',
+    presetSegmentHint: '在 WLED 中创建预设时选择目标分段,并启用“Checked segments only”。运行时,Bambuddy 只会激活已配置的预设。',
+    status: { idle: '空闲', prepare: '准备中', printing: '打印中', paused: '已暂停', finished: '已完成', error: '失败 / 错误', queue_waiting: '等待清理打印板', filament_problem: '耗材问题', hms_error: 'HMS 错误', offline: '离线' },
+  },
+
   settings: {
     sliceEngine: '切片引擎',
     sliceEngineSidecar: '服务器 sidecar',
@@ -2049,6 +2057,7 @@ export default {
       filament: '耗材',
       camera: '摄像头',
       network: '网络',
+      wled: 'WLED',
       apiKeys: 'API 密钥',
       virtualPrinter: '虚拟打印机',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2029,6 +2029,14 @@ export default {
   },
 
   // Settings page
+  wled: {
+    connectionSection: '連線', mappingSection: '狀態對應', testConnection: '測試連線', notTested: '尚未測試', connected: '已連線至 WLED{{name}}{{version}}', notReachable: '無法連線至 WLED。', testPreset: '測試', presetTested: '預設已啟用', presetTestFailed: '無法啟用預設', mappingSummary: '已設定狀態: {{configured}} · 已停用狀態: {{disabled}}',
+    title: 'WLED', description: '顏色、效果與預設均直接在 WLED 中管理。Bambuddy 僅將印表機狀態對應至現有預設。',
+    printer: '印表機', enabled: '啟用 WLED 整合', baseUrl: 'WLED 網址', loadPresets: '載入預設', online: '已載入 {{count}} 個預設', offlineHint: 'WLED 無法使用。已儲存的預設 ID 將保留。', noPrinters: '尚未設定印表機。', noPreset: '已停用 — 無預設', savedUnavailable: '已儲存預設 {{id}} — 目前無法使用', finishedTimeout: '完成逾時(秒)', finishedTimeoutHint: '時間到後由完成預設切換至閒置預設。設為 0 可停用。', saved: 'WLED 設定已儲存', saveError: '無法儲存 WLED 設定',
+    presetSegmentHint: '在 WLED 中建立預設時選擇目標區段,並啟用「Checked segments only」。執行期間,Bambuddy 只會啟用已設定的預設。',
+    status: { idle: '閒置', prepare: '準備中', printing: '列印中', paused: '已暫停', finished: '已完成', error: '失敗 / 錯誤', queue_waiting: '等待清空列印板', filament_problem: '耗材問題', hms_error: 'HMS 錯誤', offline: '離線' },
+  },
+
   settings: {
     sliceEngine: '切片引擎',
     sliceEngineSidecar: '伺服器 sidecar',
@@ -2049,6 +2057,7 @@ export default {
       filament: '耗材',
       camera: '攝影機',
       network: '網路',
+      wled: 'WLED',
       apiKeys: 'API 金鑰',
       virtualPrinter: '虛擬印表機',
       spoolbuddy: 'SpoolBuddy',

+ 1 - 0
frontend/src/lib/settingsSearch.ts

@@ -17,6 +17,7 @@ export type SettingsSearchTab =
   | 'filament'
   | 'camera'
   | 'network'
+  | 'wled'
   | 'apikeys'
   | 'virtual-printer'
   | 'spoolbuddy'

+ 10 - 4
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay, Info } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay, Info, Lightbulb } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -50,6 +50,7 @@ import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
+import { WLEDSettings } from '../components/WLEDSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
@@ -73,7 +74,7 @@ import { NumberInput } from '../components/NumberInput';
 import { PrinterSearchPicker } from '../components/PrinterSearchPicker';
 import { UsbCameraDevicePicker } from '../components/UsbCameraDevicePicker';
 
-const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'wled', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 interface SettingsTabEntry { tab: TabType; icon: LucideIcon; label: string; extra?: ReactNode }
 
@@ -319,6 +320,7 @@ export function SettingsPage() {
   // Camera tokens and the overlay builder used to live under API Keys; links
   // to them (bookmarks, the wiki, /camera-tokens) open the Camera tab instead.
   const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update');
+  const canManageWled = !authLoading && hasPermission('printers:update');
   const isLegacyCameraLink = tabParam === 'apikeys'
     && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash);
   const activeTab: TabType = isLegacyEmailTab ? 'users'
@@ -373,8 +375,9 @@ export function SettingsPage() {
   // Waits for the session so a permission not loaded yet doesn't count as none.
   useEffect(() => {
     if (!authLoading && activeTab === 'apikeys' && !canSeeApiKeysTab) handleTabChange('camera');
+    if (!authLoading && activeTab === 'wled' && !canManageWled) handleTabChange('general');
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [authLoading, activeTab, canSeeApiKeysTab]);
+  }, [authLoading, activeTab, canSeeApiKeysTab, canManageWled]);
 
   // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
   const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
@@ -1636,7 +1639,7 @@ export function SettingsPage() {
 
   // Cross-tab search is powered by the module-level registry in lib/settingsSearch.
   // Resolve i18n labels here so language changes take effect without re-registering.
-  const searchIndex = getSettingsSearchEntries().map(e => ({
+  const searchIndex = getSettingsSearchEntries().filter(e => e.tab !== 'wled' || canManageWled).map(e => ({
     ...e,
     label: t(e.labelKey, e.labelFallback ?? e.labelKey),
   }));
@@ -1704,6 +1707,7 @@ export function SettingsPage() {
     { tab: 'filament', icon: Cylinder, label: t('settings.tabs.filament') },
     { tab: 'camera', icon: Video, label: t('settings.tabs.camera') },
     { tab: 'network', icon: Wifi, label: t('settings.tabs.network'), extra: statusDot(!!mqttStatus?.enabled) },
+    ...(canManageWled ? [{ tab: 'wled', icon: Lightbulb, label: t('settings.tabs.wled') }] : []),
     // API Keys holds API keys, webhooks, the API browser and Connected Apps;
     // a user who may see none of them would land on an empty tab.
     ...(canSeeApiKeysTab
@@ -4368,6 +4372,8 @@ export function SettingsPage() {
         </div>
       )}
 
+      {activeTab === 'wled' && canManageWled && <WLEDSettings />}
+
       {/* API Keys Tab */}
       {activeTab === 'camera' && (
         <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">