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 (development - root folder only)
 /screenshots/
 /screenshots/
+output/playwright/
 
 
 # Logs
 # Logs
 *.log
 *.log

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

@@ -5,6 +5,7 @@ import secrets
 import zipfile
 import zipfile
 from pathlib import Path
 from pathlib import Path
 
 
+import httpx
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse, Response
 from fastapi.responses import FileResponse, Response
 from sqlalchemy import delete, func, select
 from sqlalchemy import delete, func, select
@@ -50,6 +51,10 @@ from backend.app.schemas.printer import (
     PrinterStatus,
     PrinterStatus,
     PrinterUpdate,
     PrinterUpdate,
     PrintOptionsResponse,
     PrintOptionsResponse,
+    WLEDConnectionInfo,
+    WLEDPreset,
+    WLEDPresetListRequest,
+    WLEDPresetTestRequest,
     hms_error_responses,
     hms_error_responses,
 )
 )
 from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
 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.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.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_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
@@ -227,6 +233,8 @@ async def create_printer(
     if await location_grantees(db, [printer.location]):
     if await location_grantees(db, [printer.location]):
         await ws_manager.refresh_printer_scopes()
         await ws_manager.refresh_printer_scopes()
 
 
+    wled_manager.configure_printer(printer.id, printer.wled_config)
+
     # Connect to the printer
     # Connect to the printer
     if printer.is_active:
     if printer.is_active:
         await printer_manager.connect_printer(printer)
         await printer_manager.connect_printer(printer)
@@ -234,6 +242,57 @@ async def create_printer(
     return 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")
 @router.get("/usb-cameras")
 async def list_usb_cameras(
 async def list_usb_cameras(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
@@ -469,11 +528,23 @@ async def update_printer(
         except Exception as e:
         except Exception as e:
             logger.error("Failed to sync virtual printers after printer IP change: %s", 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
     # Reconnect if connection settings changed
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
         printer_manager.disconnect_printer(printer_id)
         printer_manager.disconnect_printer(printer_id)
         if printer.is_active:
         if printer.is_active:
             await printer_manager.connect_printer(printer)
             await printer_manager.connect_printer(printer)
+        elif "is_active" in update_data:
+            wled_manager.handle_offline(printer_id)
 
 
     return printer
     return printer
 
 
@@ -540,6 +611,7 @@ async def delete_printer(
         notify = await delete_provider(db, provider) or notify
         notify = await delete_provider(db, provider) or notify
 
 
     printer_manager.disconnect_printer(printer_id)
     printer_manager.disconnect_printer(printer_id)
+    wled_manager.remove_printer(printer_id)
 
 
     # Locked from the archive sweep to the commit (PostgreSQL; SQLite's single
     # Locked from the archive sweep to the commit (PostgreSQL; SQLite's single
     # writer serialises this anyway). A print start adopting or writing an
     # 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")
         raise HTTPException(404, "Printer not found")
 
 
     printer_manager.disconnect_printer(printer_id)
     printer_manager.disconnect_printer(printer_id)
+    wled_manager.handle_offline(printer_id)
     return {"connected": False}
     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()):
     if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
         camera_light.invalidate_settings()
         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:
     if price_source_changed:
         # Read a newly chosen price sensor now, so the saved settings show its
         # Read a newly chosen price sensor now, so the saved settings show its
@@ -599,6 +603,9 @@ async def reset_settings(
 
 
     await db.commit()
     await db.commit()
     camera_light.invalidate_settings()
     camera_light.invalidate_settings()
+    from backend.app.services.wled import wled_manager
+
+    wled_manager.require_plate_clear = False
 
 
     return DEFAULT_SETTINGS
     return DEFAULT_SETTINGS
 
 

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

@@ -5872,6 +5872,8 @@ async def _run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("UPDATE printers SET camera_light_auto = :off WHERE camera_light_auto IS NULL"), {"off": False}
             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
     # Migration: printer-scoped groups (#1727). Defaults off, so no existing
     # group narrows anyone's printers on upgrade; the group_printers table
     # 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 import delete, func, inspect as sa_inspect, or_, select, text, update
 from sqlalchemy.engine import CursorResult
 from sqlalchemy.engine import CursorResult
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm.exc import StaleDataError
 from sqlalchemy.orm.exc import StaleDataError
 
 
 from backend.app.api.routes import (
 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.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.github_backup import github_backup_service
 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.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.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
 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,
     store_print_data as _store_spoolman_print_data,
 )
 )
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 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_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_types import printer_filament_type
 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}"
     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:
 def _hms_errors_to_notify(errors: list, new_error_codes: set[str]) -> list:
     """The new faults that count (see ``_hms_fault_counts``).
     """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)
         pause_eta.observe(printer_id, state)
     except Exception:
     except Exception:
         logging.getLogger(__name__).exception("Pause heads-up check failed for printer %s", printer_id)
         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
     # Connected-edge reconciliation (#1542 follow-up). When the printer
     # transitions disconnected → connected — which covers both Bambuddy
     # transitions disconnected → connected — which covers both Bambuddy
     # startup (no prior connection) and a mid-session MQTT reconnect — fire
     # 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")
         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
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
     # Startup
     # Startup
@@ -12062,6 +12065,11 @@ async def lifespan(app: FastAPI):
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
     await printer_manager.load_awaiting_plate_clear_from_db()
     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
     # Layer change callback for external camera timelapse
     async def on_layer_change(printer_id: int, layer_num: int):
     async def on_layer_change(printer_id: int, layer_num: int):
         """Capture timelapse frame on layer change + first layer notification."""
         """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 mqtt_relay.disconnect(timeout=2)
 
 
+    await wled_manager.shutdown()
+
     # Drop the shared Bambu Cloud HTTP client we registered at startup.
     # Drop the shared Bambu Cloud HTTP client we registered at startup.
     set_shared_http_client(None)
     set_shared_http_client(None)
     set_shared_makerworld_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 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 sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 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.
     # 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).
     # Persisted so the gate survives crashes and power cycles (issue #961).
     awaiting_plate_clear: Mapped[bool] = mapped_column(Boolean, default=False)
     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())
     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())
     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 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.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.printer_location import normalize_location_name
 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:]}"
     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):
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
     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
     # PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     access_code: str = Field(..., min_length=1, max_length=20)
     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,
     # 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.
     # 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
     camera_light_auto: bool | None = None  # #1655
     plate_detection_enabled: bool | None = None
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
+    wled_config: WLEDConfig | None = None
 
 
 
 
 class PrinterResponse(PrinterBase):
 class PrinterResponse(PrinterBase):
+    wled_config: WLEDStoredConfig | None = None
     id: int
     id: int
     is_active: bool
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
     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.
     way. 0 for anything else, which Bambu defines as an invalid level.
     """
     """
     return {0x4: 1, 0x8: 2, 0xC: 3}.get((error >> 12) & 0xF, 0)
     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:
         except Exception as e:
             logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, 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"
         # 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.
         # is not an action item, and the queue clears the gate by itself.
         if not awaiting:
         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()}
     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:
 class TestVisibility:
     async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
     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")
         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 unittest.mock import AsyncMock, MagicMock, patch
 from urllib.parse import unquote
 from urllib.parse import unquote
 
 
+import httpx
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
@@ -75,6 +76,11 @@ class TestPrintersAPI:
             "access_code": "12345678",
             "access_code": "12345678",
             "is_active": True,
             "is_active": True,
             "model": "X1C",
             "model": "X1C",
+            "wled_config": {
+                "enabled": True,
+                "base_url": "http://wled.local/",
+                "presets": {"printing": 3},
+            },
         }
         }
 
 
         response = await async_client.post("/api/v1/printers/", json=data)
         response = await async_client.post("/api/v1/printers/", json=data)
@@ -84,6 +90,8 @@ class TestPrintersAPI:
         assert result["name"] == "New Printer"
         assert result["name"] == "New Printer"
         assert result["serial_number"] == "00M09A111111111"
         assert result["serial_number"] == "00M09A111111111"
         assert result["model"] == "X1C"
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -244,6 +252,36 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["is_active"] is False
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
     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.status_code == 200
         assert response.json()["auto_archive"] is False
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_nonexistent_printer(self, async_client: AsyncClient):
     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["auto_archive"], bool)
         assert isinstance(result["currency"], str)
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_unset_temp_alarm_reads_back_as_null(self, async_client: AsyncClient, db_session):
     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"),
     ("OIDCProviderCreate", "icon_url"),
     ("OIDCProviderUpdate", "issuer_url"),
     ("OIDCProviderUpdate", "issuer_url"),
     ("OIDCProviderUpdate", "icon_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
     # Gitea/Forgejo derive their API base from this and request it with the
     # stored token, so it is a real fetch target — guarded in
     # stored token, so it is a real fetch target — guarded in
     # github_backup._enforce_private_repo, which both POST and PATCH funnel through.
     # 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
 import pytest
 from pydantic import ValidationError
 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:
 def _make(serial: str) -> PrinterCreate:
@@ -40,3 +40,45 @@ def test_already_normalized_serial_unchanged():
 def test_blank_serial_number_rejected():
 def test_blank_serial_number_rejected():
     with pytest.raises(ValidationError):
     with pytest.raises(ValidationError):
         _make("   ")
         _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 />);
       render(<SettingsPage />);
       await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
       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)', () => {
   describe('external camera snapshot URL override (#1177)', () => {

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

@@ -433,6 +433,36 @@ export interface PrinterLocationUpdate {
   color?: string | null;
   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 {
 export interface Printer {
   id: number;
   id: number;
   name: string;
   name: string;
@@ -460,6 +490,7 @@ export interface Printer {
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   wear_cost_per_hour: number | null;  // Wear cost per printing hour (#694)
   wear_cost_per_hour: number | null;  // Wear cost per printing hour (#694)
+  wled_config: WLEDConfig | null;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }
@@ -785,6 +816,7 @@ export interface PrinterCreate {
   plate_detection_enabled?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
   plate_detection_roi?: PlateDetectionROI;
   wear_cost_per_hour?: number | null;
   wear_cost_per_hour?: number | null;
+  wled_config?: WLEDConfig | null;
 }
 }
 
 
 // Plate Detection
 // Plate Detection
@@ -5368,6 +5400,21 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify(data),
       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) =>
   deletePrinter: (id: number, deleteArchives: boolean = true) =>
     request<{ status: string; archives_deleted: boolean }>(
     request<{ status: string; archives_deleted: boolean }>(
       `/printers/${id}?delete_archives=${deleteArchives}`,
       `/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
   // 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: {
   settings: {
     sliceEngine: 'Slice-Engine',
     sliceEngine: 'Slice-Engine',
     sliceEngineSidecar: 'Server-Sidecar',
     sliceEngineSidecar: 'Server-Sidecar',
@@ -2050,6 +2058,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Kamera',
       camera: 'Kamera',
       network: 'Netzwerk',
       network: 'Netzwerk',
+      wled: 'WLED',
       apiKeys: 'API-Schlüssel',
       apiKeys: 'API-Schlüssel',
       virtualPrinter: 'Virtueller Drucker',
       virtualPrinter: 'Virtueller Drucker',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2050,6 +2050,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Slice engine',
     sliceEngine: 'Slice engine',
     sliceEngineSidecar: 'Server sidecar',
     sliceEngineSidecar: 'Server sidecar',
@@ -2070,6 +2078,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Camera',
       camera: 'Camera',
       network: 'Network',
       network: 'Network',
+      wled: 'WLED',
       apiKeys: 'API Keys',
       apiKeys: 'API Keys',
       virtualPrinter: 'Virtual Printer',
       virtualPrinter: 'Virtual Printer',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2031,6 +2031,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Motor de laminado',
     sliceEngine: 'Motor de laminado',
     sliceEngineSidecar: 'Sidecar del servidor',
     sliceEngineSidecar: 'Sidecar del servidor',
@@ -2051,6 +2059,7 @@ export default {
       filament: 'Filamento',
       filament: 'Filamento',
       camera: 'Cámara',
       camera: 'Cámara',
       network: 'Red',
       network: 'Red',
+      wled: 'WLED',
       apiKeys: 'Claves API',
       apiKeys: 'Claves API',
       virtualPrinter: 'Impresora virtual',
       virtualPrinter: 'Impresora virtual',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2029,6 +2029,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Moteur de découpage',
     sliceEngine: 'Moteur de découpage',
     sliceEngineSidecar: 'Sidecar serveur',
     sliceEngineSidecar: 'Sidecar serveur',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Caméra',
       camera: 'Caméra',
       network: 'Réseau',
       network: 'Réseau',
+      wled: 'WLED',
       apiKeys: 'Clés API',
       apiKeys: 'Clés API',
       virtualPrinter: 'Imprimante virtuelle',
       virtualPrinter: 'Imprimante virtuelle',
       failureDetection: 'Détection d\'échec',
       failureDetection: 'Détection d\'échec',

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

@@ -2029,6 +2029,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Motore di slicing',
     sliceEngine: 'Motore di slicing',
     sliceEngineSidecar: 'Sidecar del server',
     sliceEngineSidecar: 'Sidecar del server',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'Filamento',
       filament: 'Filamento',
       camera: 'Telecamera',
       camera: 'Telecamera',
       network: 'Rete',
       network: 'Rete',
+      wled: 'WLED',
       apiKeys: 'Chiavi API',
       apiKeys: 'Chiavi API',
       virtualPrinter: 'Stampante virtuale',
       virtualPrinter: 'Stampante virtuale',
       failureDetection: 'Rilevamento guasti',
       failureDetection: 'Rilevamento guasti',

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

@@ -2029,6 +2029,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'スライスエンジン',
     sliceEngine: 'スライスエンジン',
     sliceEngineSidecar: 'サーバーサイドカー',
     sliceEngineSidecar: 'サーバーサイドカー',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'フィラメント',
       filament: 'フィラメント',
       camera: 'カメラ',
       camera: 'カメラ',
       network: 'ネットワーク',
       network: 'ネットワーク',
+      wled: 'WLED',
       apiKeys: 'APIキー',
       apiKeys: 'APIキー',
       virtualPrinter: '仮想プリンター',
       virtualPrinter: '仮想プリンター',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -1943,6 +1943,14 @@ export default {
     noPermissionEditIntervals: '간격을 편집할 권한이 없습니다',
     noPermissionEditIntervals: '간격을 편집할 권한이 없습니다',
     configureSettings: '유지보수 유형 및 간격 설정'
     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: {
   settings: {
     sliceEngine: '슬라이스 엔진',
     sliceEngine: '슬라이스 엔진',
     sliceEngineSidecar: '서버 사이드카',
     sliceEngineSidecar: '서버 사이드카',
@@ -1962,6 +1970,7 @@ export default {
       filament: '필라멘트',
       filament: '필라멘트',
       camera: '카메라',
       camera: '카메라',
       network: '네트워크',
       network: '네트워크',
+      wled: 'WLED',
       apiKeys: 'API 키',
       apiKeys: 'API 키',
       virtualPrinter: '가상 프린터',
       virtualPrinter: '가상 프린터',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2048,6 +2048,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Slice-engine',
     sliceEngine: 'Slice-engine',
     sliceEngineSidecar: 'Server-sidecar',
     sliceEngineSidecar: 'Server-sidecar',
@@ -2068,6 +2076,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Camera',
       camera: 'Camera',
       network: 'Netwerk',
       network: 'Netwerk',
+      wled: 'WLED',
       apiKeys: 'API-sleutels',
       apiKeys: 'API-sleutels',
       virtualPrinter: 'Virtuele printer',
       virtualPrinter: 'Virtuele printer',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2050,6 +2050,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Silnik slicowania',
     sliceEngine: 'Silnik slicowania',
     sliceEngineSidecar: 'Sidecar na serwerze',
     sliceEngineSidecar: 'Sidecar na serwerze',
@@ -2070,6 +2078,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Kamera',
       camera: 'Kamera',
       network: 'Sieć',
       network: 'Sieć',
+      wled: 'WLED',
       apiKeys: 'Klucze API',
       apiKeys: 'Klucze API',
       virtualPrinter: 'Wirtualna drukarka',
       virtualPrinter: 'Wirtualna drukarka',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2029,6 +2029,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Motor de fatiamento',
     sliceEngine: 'Motor de fatiamento',
     sliceEngineSidecar: 'Sidecar do servidor',
     sliceEngineSidecar: 'Sidecar do servidor',
@@ -2049,6 +2057,7 @@ export default {
       filament: 'Filamento',
       filament: 'Filamento',
       camera: 'Câmera',
       camera: 'Câmera',
       network: 'Rede',
       network: 'Rede',
+      wled: 'WLED',
       apiKeys: 'Chaves API',
       apiKeys: 'Chaves API',
       virtualPrinter: 'Impressora Virtual',
       virtualPrinter: 'Impressora Virtual',
       failureDetection: 'Detecção de Falhas',
       failureDetection: 'Detecção de Falhas',

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

@@ -1940,6 +1940,14 @@ export default {
     noPermissionEditIntervals: "У вас нет разрешения изменять интервалы",
     noPermissionEditIntervals: "У вас нет разрешения изменять интервалы",
     configureSettings: "Настроить виды обслуживания и интервалы",
     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: {
   settings: {
     sliceEngine: 'Движок нарезки',
     sliceEngine: 'Движок нарезки',
     sliceEngineSidecar: 'Серверный sidecar',
     sliceEngineSidecar: 'Серверный sidecar',
@@ -1959,6 +1967,7 @@ export default {
       filament: "Филамент",
       filament: "Филамент",
       camera: 'Камера',
       camera: 'Камера',
       network: "Сеть",
       network: "Сеть",
+      wled: "WLED",
       apiKeys: "Ключи API",
       apiKeys: "Ключи API",
       virtualPrinter: "Виртуальный принтер",
       virtualPrinter: "Виртуальный принтер",
       spoolbuddy: "SpoolBuddy",
       spoolbuddy: "SpoolBuddy",

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

@@ -2047,6 +2047,47 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Slicer-motor',
     sliceEngine: 'Slicer-motor',
     sliceEngineSidecar: 'Server-sidecar',
     sliceEngineSidecar: 'Server-sidecar',
@@ -2067,6 +2108,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Kamera',
       camera: 'Kamera',
       network: 'Nätverk',
       network: 'Nätverk',
+      wled: 'WLED',
       apiKeys: 'API-nycklar',
       apiKeys: 'API-nycklar',
       virtualPrinter: 'Virtuell skrivare',
       virtualPrinter: 'Virtuell skrivare',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2031,6 +2031,14 @@ export default {
   },
   },
 
 
   // Ayarlar sayfası
   // 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: {
   settings: {
     sliceEngine: 'Dilimleme motoru',
     sliceEngine: 'Dilimleme motoru',
     sliceEngineSidecar: 'Sunucu sidecar',
     sliceEngineSidecar: 'Sunucu sidecar',
@@ -2051,6 +2059,7 @@ export default {
       filament: 'Filament',
       filament: 'Filament',
       camera: 'Kamera',
       camera: 'Kamera',
       network: 'Ağ',
       network: 'Ağ',
+      wled: 'WLED',
       apiKeys: 'API Anahtarları',
       apiKeys: 'API Anahtarları',
       virtualPrinter: 'Sanal Yazıcı',
       virtualPrinter: 'Sanal Yazıcı',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2046,6 +2046,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: 'Рушій нарізання',
     sliceEngine: 'Рушій нарізання',
     sliceEngineSidecar: 'Серверний sidecar',
     sliceEngineSidecar: 'Серверний sidecar',
@@ -2066,6 +2074,7 @@ export default {
       filament: "Філамент",
       filament: "Філамент",
       camera: 'Камера',
       camera: 'Камера',
       network: "Мережа",
       network: "Мережа",
+      wled: "WLED",
       apiKeys: "Ключі API",
       apiKeys: "Ключі API",
       virtualPrinter: "Віртуальний принтер",
       virtualPrinter: "Віртуальний принтер",
       spoolbuddy: "SpoolBuddy",
       spoolbuddy: "SpoolBuddy",

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

@@ -2029,6 +2029,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: '切片引擎',
     sliceEngine: '切片引擎',
     sliceEngineSidecar: '服务器 sidecar',
     sliceEngineSidecar: '服务器 sidecar',
@@ -2049,6 +2057,7 @@ export default {
       filament: '耗材',
       filament: '耗材',
       camera: '摄像头',
       camera: '摄像头',
       network: '网络',
       network: '网络',
+      wled: 'WLED',
       apiKeys: 'API 密钥',
       apiKeys: 'API 密钥',
       virtualPrinter: '虚拟打印机',
       virtualPrinter: '虚拟打印机',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

@@ -2029,6 +2029,14 @@ export default {
   },
   },
 
 
   // Settings page
   // 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: {
   settings: {
     sliceEngine: '切片引擎',
     sliceEngine: '切片引擎',
     sliceEngineSidecar: '伺服器 sidecar',
     sliceEngineSidecar: '伺服器 sidecar',
@@ -2049,6 +2057,7 @@ export default {
       filament: '耗材',
       filament: '耗材',
       camera: '攝影機',
       camera: '攝影機',
       network: '網路',
       network: '網路',
+      wled: 'WLED',
       apiKeys: 'API 金鑰',
       apiKeys: 'API 金鑰',
       virtualPrinter: '虛擬印表機',
       virtualPrinter: '虛擬印表機',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',

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

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

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

@@ -1,5 +1,5 @@
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 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 { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
@@ -50,6 +50,7 @@ import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
 import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
+import { WLEDSettings } from '../components/WLEDSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
 import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
@@ -73,7 +74,7 @@ import { NumberInput } from '../components/NumberInput';
 import { PrinterSearchPicker } from '../components/PrinterSearchPicker';
 import { PrinterSearchPicker } from '../components/PrinterSearchPicker';
 import { UsbCameraDevicePicker } from '../components/UsbCameraDevicePicker';
 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];
 type TabType = typeof validTabs[number];
 interface SettingsTabEntry { tab: TabType; icon: LucideIcon; label: string; extra?: ReactNode }
 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
   // 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.
   // to them (bookmarks, the wiki, /camera-tokens) open the Camera tab instead.
   const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update');
   const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update');
+  const canManageWled = !authLoading && hasPermission('printers:update');
   const isLegacyCameraLink = tabParam === 'apikeys'
   const isLegacyCameraLink = tabParam === 'apikeys'
     && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash);
     && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash);
   const activeTab: TabType = isLegacyEmailTab ? 'users'
   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.
   // Waits for the session so a permission not loaded yet doesn't count as none.
   useEffect(() => {
   useEffect(() => {
     if (!authLoading && activeTab === 'apikeys' && !canSeeApiKeysTab) handleTabChange('camera');
     if (!authLoading && activeTab === 'apikeys' && !canSeeApiKeysTab) handleTabChange('camera');
+    if (!authLoading && activeTab === 'wled' && !canManageWled) handleTabChange('general');
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // 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.
   // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
   const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
   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.
   // 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.
   // 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,
     ...e,
     label: t(e.labelKey, e.labelFallback ?? e.labelKey),
     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: 'filament', icon: Cylinder, label: t('settings.tabs.filament') },
     { tab: 'camera', icon: Video, label: t('settings.tabs.camera') },
     { tab: 'camera', icon: Video, label: t('settings.tabs.camera') },
     { tab: 'network', icon: Wifi, label: t('settings.tabs.network'), extra: statusDot(!!mqttStatus?.enabled) },
     { 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;
     // 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.
     // a user who may see none of them would land on an empty tab.
     ...(canSeeApiKeysTab
     ...(canSeeApiKeysTab
@@ -4368,6 +4372,8 @@ export function SettingsPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {activeTab === 'wled' && canManageWled && <WLEDSettings />}
+
       {/* API Keys Tab */}
       {/* API Keys Tab */}
       {activeTab === 'camera' && (
       {activeTab === 'camera' && (
         <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
         <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">