Browse Source

Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)

Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
Keybored 2 months ago
parent
commit
3f7d0e2d55

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -43,6 +43,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_print_failed": provider.on_print_failed,
         "on_print_stopped": provider.on_print_stopped,
         "on_print_progress": provider.on_print_progress,
+        "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
@@ -122,6 +123,7 @@ async def create_notification_provider(
         on_print_failed=provider_data.on_print_failed,
         on_print_stopped=provider_data.on_print_stopped,
         on_print_progress=provider_data.on_print_progress,
+        on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,

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

@@ -254,6 +254,14 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add missing-spool-assignment print-start notification toggle
+    try:
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0")
+        )
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add project_id column to print_archives
     try:
         await conn.execute(

+ 16 - 0
backend/app/core/websocket.py

@@ -91,6 +91,22 @@ class ConnectionManager:
             }
         )
 
+    async def send_missing_spool_assignment(
+        self,
+        printer_id: int,
+        printer_name: str,
+        missing_slots: list[dict[str, str]],
+    ):
+        """Notify clients that a print started with missing spool assignments."""
+        await self.broadcast(
+            {
+                "type": "missing_spool_assignment",
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "missing_slots": missing_slots,
+            }
+        )
+
 
 # Global connection manager
 ws_manager = ConnectionManager()

+ 6 - 0
backend/app/main.py

@@ -73,6 +73,9 @@ from backend.app.services.printer_manager import (
     printer_state_to_dict,
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.services.spool_assignment_notifications import (
+    notify_missing_spool_assignments_on_print_start,
+)
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman_tracking import (
     cleanup_tracking as _cleanup_spoolman_tracking,
@@ -1214,6 +1217,9 @@ async def on_print_start(printer_id: int, data: dict):
 
     await ws_manager.send_print_start(printer_id, data)
 
+    # Notify when the print-start AMS mapping references tray slots without spool assignments.
+    await notify_missing_spool_assignments_on_print_start(printer_id, data, logger)
+
     # MQTT relay - publish print start
     try:
         printer_info = printer_manager.get_printer(printer_id)

+ 1 - 0
backend/app/models/notification.py

@@ -65,6 +65,7 @@ class NotificationProvider(Base):
     on_print_failed = Column(Boolean, default=True)
     on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
+    on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
 
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)

+ 6 - 0
backend/app/models/notification_template.py

@@ -55,6 +55,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Print {progress}% Complete",
         "body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
     },
+    {
+        "event_type": "print_missing_spool_assignment",
+        "name": "Missing Spool Assignment",
+        "title_template": "Missing Spool Assignment",
+        "body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
+    },
     {
         "event_type": "printer_offline",
         "name": "Printer Offline",

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

@@ -35,6 +35,10 @@ class NotificationProviderBase(BaseModel):
     on_print_failed: bool = Field(default=True, description="Notify on print failed")
     on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
     on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
+    on_print_missing_spool_assignment: bool = Field(
+        default=False,
+        description="Notify when a print starts with required trays missing spool assignments",
+    )
 
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
@@ -119,6 +123,7 @@ class NotificationProviderUpdate(BaseModel):
     on_print_failed: bool | None = None
     on_print_stopped: bool | None = None
     on_print_progress: bool | None = None
+    on_print_missing_spool_assignment: bool | None = None
 
     # Event triggers - printer status
     on_printer_offline: bool | None = None

+ 15 - 0
backend/app/schemas/notification_template.py

@@ -15,6 +15,7 @@ class EventType(StrEnum):
     PRINT_FAILED = "print_failed"
     PRINT_STOPPED = "print_stopped"
     PRINT_PROGRESS = "print_progress"
+    PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_ERROR = "printer_error"
     FILAMENT_LOW = "filament_low"
@@ -62,6 +63,13 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "app_name",
     ],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "eta", "timestamp", "app_name"],
+    "print_missing_spool_assignment": [
+        "printer",
+        "missing_slots",
+        "missing_slot_details",
+        "timestamp",
+        "app_name",
+    ],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -140,6 +148,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
     },
+    "print_missing_spool_assignment": {
+        "printer": "Bambu X1C",
+        "missing_slots": "A1, A3",
+        "missing_slot_details": "- A1: PLA Basic\n- A3: PETG HF",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "printer_offline": {
         "printer": "Bambu X1C",
         "timestamp": "2024-01-15 14:30",

+ 41 - 0
backend/app/services/notification_service.py

@@ -934,6 +934,47 @@ class NotificationService:
             providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
         )
 
+    async def on_print_missing_spool_assignment(
+        self,
+        printer_id: int,
+        printer_name: str,
+        missing_slots: list[dict[str, str]],
+        db: AsyncSession,
+    ):
+        """Handle print-start event when required trays are missing spool assignments."""
+        if not missing_slots:
+            return
+
+        providers = await self._get_providers_for_event(db, "on_print_missing_spool_assignment", printer_id)
+        if not providers:
+            return
+
+        missing_slot_names = ", ".join(slot.get("slot", "Unknown") for slot in missing_slots)
+        detail_lines = []
+        for slot in missing_slots:
+            slot_name = slot.get("slot", "Unknown")
+            profile = slot.get("profile", "Unknown")
+            detail_lines.append(f"- {slot_name}: {profile}")
+        missing_profile_details = "\n".join(detail_lines)
+
+        variables = {
+            "printer": printer_name,
+            "missing_slots": missing_slot_names,
+            "missing_slot_details": missing_profile_details,
+        }
+
+        title, message = await self._build_message_from_template(db, "print_missing_spool_assignment", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "print_missing_spool_assignment",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+        )
+
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)

+ 169 - 0
backend/app/services/spool_assignment_notifications.py

@@ -0,0 +1,169 @@
+import logging
+
+from backend.app.core.database import async_session
+from backend.app.core.websocket import ws_manager
+from backend.app.models.printer import Printer
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.notification_service import notification_service
+from backend.app.services.printer_manager import printer_manager
+
+
+def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
+    """Convert an assignment tuple to Bambuddy global tray ID."""
+    if ams_id in (254, 255):
+        return 254 + tray_id
+    if ams_id >= 128:
+        return ams_id
+    return ams_id * 4 + tray_id
+
+
+def _slot_label_from_global_tray(global_tray_id: int) -> str:
+    """Return a human-readable slot label from a global tray ID."""
+    if global_tray_id == 254:
+        return "Ext-L"
+    if global_tray_id == 255:
+        return "Ext-R"
+    if global_tray_id >= 128:
+        return f"HT-{chr(65 + (global_tray_id - 128))}"
+    ams_id = global_tray_id // 4
+    tray_id = global_tray_id % 4
+    return f"{chr(65 + ams_id)}{tray_id + 1}"
+
+
+def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:
+    """Resolve expected tray material/profile and color for a global tray ID from current printer state."""
+    if not state or not state.raw_data:
+        return ("Unknown", "Unknown")
+
+    ams_raw = state.raw_data.get("ams", {})
+    ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+
+    vt_trays = state.raw_data.get("vt_tray", [])
+    if not isinstance(vt_trays, list):
+        vt_trays = []
+
+    for tray in vt_trays:
+        if not isinstance(tray, dict):
+            continue
+        if int(tray.get("id", -1)) == global_tray_id:
+            profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
+            color = tray.get("tray_color") or "Unknown"
+            return (profile, color)
+
+    for ams in ams_units:
+        if not isinstance(ams, dict):
+            continue
+        ams_id = int(ams.get("id", -1))
+        trays = ams.get("tray", [])
+        if not isinstance(trays, list):
+            continue
+        for tray in trays:
+            if not isinstance(tray, dict):
+                continue
+            tray_id = int(tray.get("id", -1))
+            candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
+            if candidate == global_tray_id:
+                profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
+                color = tray.get("tray_color") or "Unknown"
+                return (profile, color)
+
+    return ("Unknown", "Unknown")
+
+
+def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
+    """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
+    if not isinstance(mapping_raw, list) or not mapping_raw:
+        return []
+
+    decoded: list[int] = []
+    for value in mapping_raw:
+        try:
+            if isinstance(value, int):
+                encoded = value
+            elif isinstance(value, str):
+                encoded = int(value, 10)
+            else:
+                continue
+        except ValueError:
+            continue
+
+        if encoded >= 65535:
+            continue
+
+        ams_hw_id = (encoded >> 8) & 0xFF
+        slot = encoded & 0xFF
+
+        if 0 <= ams_hw_id <= 3:
+            decoded.append(ams_hw_id * 4 + (slot & 0x03))
+        elif 128 <= ams_hw_id <= 135:
+            decoded.append(ams_hw_id)
+        elif ams_hw_id in (254, 255):
+            decoded.append(255 if slot == 255 else 254)
+
+    return decoded
+
+
+async def notify_missing_spool_assignments_on_print_start(
+    printer_id: int,
+    data: dict,
+    logger: logging.Logger,
+) -> None:
+    """Send notification when print-start mapping references unassigned trays."""
+    explicit_mapping = data.get("ams_mapping")
+    explicit_values = (
+        [value for value in explicit_mapping if isinstance(value, int)]
+        if isinstance(explicit_mapping, list)
+        else []
+    )
+    raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
+    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
+    mapping_values = explicit_values if explicit_values else decoded_values
+
+    used_global_trays = {value for value in mapping_values if value >= 0}
+    if not used_global_trays:
+        return
+
+    try:
+        async with async_session() as db:
+            printer = await db.get(Printer, printer_id)
+            printer_name = printer.name if printer else f"Printer {printer_id}"
+
+            assignments_result = await db.execute(
+                SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id)
+            )
+            assignments = assignments_result.fetchall()
+            assigned_global_trays = {
+                _global_tray_from_assignment(assignment.ams_id, assignment.tray_id) for assignment in assignments
+            }
+
+            missing_global = sorted(used_global_trays - assigned_global_trays)
+            if not missing_global:
+                return
+
+            state = printer_manager.get_status(printer_id)
+            missing_slots = []
+            for global_id in missing_global:
+                profile, color = _tray_profile_and_color_for_global_id(state, global_id)
+                missing_slots.append(
+                    {
+                        "slot": _slot_label_from_global_tray(global_id),
+                        "profile": profile,
+                        "color": color,
+                    }
+                )
+
+            await ws_manager.send_missing_spool_assignment(
+                printer_id=printer_id,
+                printer_name=printer_name,
+                missing_slots=missing_slots,
+            )
+
+            await notification_service.on_print_missing_spool_assignment(
+                printer_id=printer_id,
+                printer_name=printer_name,
+                missing_slots=missing_slots,
+                db=db,
+            )
+    except Exception as e:
+        logger.warning("Missing spool-assignment notification failed: %s", e)

+ 99 - 47
backend/app/services/usage_tracker.py

@@ -164,6 +164,70 @@ class PrintSession:
 _active_sessions: dict[int, PrintSession] = {}
 
 
+def _to_epoch_seconds(value: datetime | None) -> float | None:
+    """Convert datetime to epoch seconds, assuming UTC for naive values."""
+    if value is None:
+        return None
+    dt = value
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt.timestamp()
+
+
+async def _resolve_spool_id_for_tray(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession,
+    spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
+    print_started_at: datetime | None = None,
+) -> int | None:
+    """Resolve spool ID for a tray with safe support for mid-print reassignment.
+
+    Resolution order:
+    1. If snapshot exists and live assignment changed *during this print*, use live spool.
+    2. Otherwise use snapshot spool when available.
+    3. Fall back to live assignment.
+    """
+    key = (ams_id, tray_id)
+    snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
+
+    # Backward-compatible fast path: if we have a snapshot but no print-start
+    # timestamp, preserve legacy behavior and avoid extra DB lookups.
+    if snapshot_spool_id is not None and print_started_at is None:
+        return snapshot_spool_id
+
+    result = await db.execute(
+        select(SpoolAssignment).where(
+            SpoolAssignment.printer_id == printer_id,
+            SpoolAssignment.ams_id == ams_id,
+            SpoolAssignment.tray_id == tray_id,
+        )
+    )
+    live_assignment = result.scalar_one_or_none()
+
+    if snapshot_spool_id is not None:
+        if live_assignment and live_assignment.spool_id != snapshot_spool_id:
+            live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
+            started_ts = _to_epoch_seconds(print_started_at)
+            if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
+                logger.info(
+                    "[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    snapshot_spool_id,
+                    live_assignment.spool_id,
+                )
+                return live_assignment.spool_id
+        return snapshot_spool_id
+
+    if live_assignment:
+        return live_assignment.spool_id
+
+    return None
+
+
 async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
     """Capture AMS tray remain% and spool assignments at print start."""
     state = printer_manager.get_status(printer_id)
@@ -323,6 +387,7 @@ async def on_print_complete(
             last_layer_num=data.get("last_layer_num", 0),
             default_filament_cost=default_filament_cost,
             spool_assignments=session.spool_assignments if session else None,
+            print_started_at=session.started_at if session else None,
         )
         results.extend(threemf_results)
 
@@ -357,20 +422,16 @@ async def on_print_complete(
                     if delta_pct <= 0:
                         continue  # No consumption or tray was refilled
 
-                    # Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
-                    spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
+                    spool_id = await _resolve_spool_id_for_tray(
+                        printer_id=printer_id,
+                        ams_id=ams_id,
+                        tray_id=tray_id,
+                        db=db,
+                        spool_assignments_snapshot=session.spool_assignments,
+                        print_started_at=session.started_at,
+                    )
                     if spool_id is None:
-                        result = await db.execute(
-                            select(SpoolAssignment).where(
-                                SpoolAssignment.printer_id == printer_id,
-                                SpoolAssignment.ams_id == ams_id,
-                                SpoolAssignment.tray_id == tray_id,
-                            )
-                        )
-                        assignment = result.scalar_one_or_none()
-                        if not assignment:
-                            continue
-                        spool_id = assignment.spool_id
+                        continue
 
                     # Load spool
                     spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
@@ -463,6 +524,7 @@ async def _track_from_3mf(
     last_layer_num: int = 0,
     default_filament_cost: float = 0.0,
     spool_assignments: dict[tuple[int, int], int] | None = None,
+    print_started_at: datetime | None = None,
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
 
@@ -726,26 +788,22 @@ async def _track_from_3mf(
                     segment_grams,
                 )
 
-                # Find spool for this tray
-                seg_spool_id = spool_assignments.get(seg_key) if spool_assignments else None
+                seg_spool_id = await _resolve_spool_id_for_tray(
+                    printer_id=printer_id,
+                    ams_id=seg_ams_id,
+                    tray_id=seg_tray_id,
+                    db=db,
+                    spool_assignments_snapshot=spool_assignments,
+                    print_started_at=print_started_at,
+                )
                 if seg_spool_id is None:
-                    assign_result = await db.execute(
-                        select(SpoolAssignment).where(
-                            SpoolAssignment.printer_id == printer_id,
-                            SpoolAssignment.ams_id == seg_ams_id,
-                            SpoolAssignment.tray_id == seg_tray_id,
-                        )
+                    logger.info(
+                        "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
+                        printer_id,
+                        seg_ams_id,
+                        seg_tray_id,
                     )
-                    assignment = assign_result.scalar_one_or_none()
-                    if not assignment:
-                        logger.info(
-                            "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
-                            printer_id,
-                            seg_ams_id,
-                            seg_tray_id,
-                        )
-                        continue
-                    seg_spool_id = assignment.spool_id
+                    continue
 
                 spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
                 spool = spool_result.scalar_one_or_none()
@@ -851,23 +909,17 @@ async def _track_from_3mf(
         if key in handled_trays:
             continue
 
-        # Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
-        spool_id = spool_assignments.get(key) if spool_assignments else None
+        spool_id = await _resolve_spool_id_for_tray(
+            printer_id=printer_id,
+            ams_id=ams_id,
+            tray_id=tray_id,
+            db=db,
+            spool_assignments_snapshot=spool_assignments,
+            print_started_at=print_started_at,
+        )
         if spool_id is None:
-            assign_result = await db.execute(
-                select(SpoolAssignment).where(
-                    SpoolAssignment.printer_id == printer_id,
-                    SpoolAssignment.ams_id == ams_id,
-                    SpoolAssignment.tray_id == tray_id,
-                )
-            )
-            assignment = assign_result.scalar_one_or_none()
-            if not assignment:
-                logger.info(
-                    "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
-                )
-                continue
-            spool_id = assignment.spool_id
+            logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
+            continue
 
         # Load spool
         spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))

+ 1 - 0
backend/tests/conftest.py

@@ -427,6 +427,7 @@ def notification_provider_factory(db_session):
             "on_print_failed": True,
             "on_print_stopped": True,
             "on_print_progress": False,
+            "on_print_missing_spool_assignment": False,
             "on_printer_offline": False,
             "on_printer_error": False,
             "on_filament_low": False,

+ 36 - 0
backend/tests/integration/test_notifications_api.py

@@ -391,6 +391,42 @@ class TestNotificationsAPI:
         assert result["on_bed_cooled"] is False
         assert result["on_first_layer_complete"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):
+        """Verify missing spool assignment toggle persists on create."""
+        data = {
+            "name": "Missing Spool Assignment Test",
+            "provider_type": "ntfy",
+            "config": {"server": "https://ntfy.sh", "topic": "test"},
+            "on_print_missing_spool_assignment": True,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_print_missing_spool_assignment"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_missing_spool_assignment_toggle(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """CRITICAL: Verify missing spool assignment toggle persists correctly."""
+        provider = await notification_provider_factory(on_print_missing_spool_assignment=False)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_print_missing_spool_assignment": True},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_print_missing_spool_assignment"] is True
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_print_missing_spool_assignment"] is True
+
 
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""

+ 77 - 0
backend/tests/unit/services/test_spool_assignment_notifications.py

@@ -0,0 +1,77 @@
+"""Unit tests for spool assignment notification service."""
+
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
+
+
+class _FakeAssignmentsResult:
+    def __init__(self, rows):
+        self._rows = rows
+
+    def fetchall(self):
+        return self._rows
+
+
+class _FakeSession:
+    def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):
+        self._printer = SimpleNamespace(name=printer_name)
+        self._assignments = assignments
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+        return False
+
+    async def get(self, model, key):
+        return self._printer
+
+    async def execute(self, statement):
+        return _FakeAssignmentsResult(self._assignments)
+
+
+@pytest.mark.asyncio
+async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
+    """When a mapped tray is unassigned, service emits websocket and notification events."""
+    logger = logging.getLogger(__name__)
+    data = {
+        "ams_mapping": [1],
+        "raw_data": {},
+    }
+
+    # Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
+    assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
+
+    with (
+        patch(
+            "backend.app.services.spool_assignment_notifications.async_session",
+            return_value=_FakeSession("Printer A", assignments),
+        ),
+        patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
+            new_callable=AsyncMock,
+        ) as mock_ws,
+        patch(
+            "backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
+            new_callable=AsyncMock,
+        ) as mock_notify,
+    ):
+        await notify_missing_spool_assignments_on_print_start(1, data, logger)
+
+    mock_ws.assert_awaited_once()
+    ws_kwargs = mock_ws.await_args.kwargs
+    assert ws_kwargs["printer_id"] == 1
+    assert ws_kwargs["printer_name"] == "Printer A"
+    assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
+
+    mock_notify.assert_awaited_once()
+    notify_kwargs = mock_notify.await_args.kwargs
+    assert notify_kwargs["printer_id"] == 1
+    assert notify_kwargs["printer_name"] == "Printer A"
+    assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]

+ 7 - 3
backend/tests/unit/services/test_usage_tracker.py

@@ -32,13 +32,14 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
     return spool
 
 
-def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
+def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
     """Create a mock SpoolAssignment object."""
     assignment = MagicMock()
     assignment.spool_id = spool_id
     assignment.printer_id = printer_id
     assignment.ams_id = ams_id
     assignment.tray_id = tray_id
+    assignment.created_at = created_at or datetime.now(timezone.utc)
     return assignment
 
 
@@ -570,10 +571,11 @@ class TestSpoolAssignmentSnapshot:
         ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
         pm = _make_printer_manager(_make_printer_state(ams_data))
 
-        # db only returns spool (NO assignment query)
+        # db returns no live assignment, then spool from snapshot spool_id
         db = AsyncMock()
         db.execute = AsyncMock(
             side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
             ]
         )
@@ -657,13 +659,15 @@ class TestSpoolAssignmentSnapshot:
 
         filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
 
-        # db: archive, queue_item(None), spool, then cost aggregation queries
+        # db: archive, queue_item(None), live assignment(None), spool,
+        # then cost aggregation queries
         # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
         db = AsyncMock()
         db.execute = AsyncMock(
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 # Cost aggregation: sum query (uses .scalar()), archive lookup
                 MagicMock(scalar=MagicMock(return_value=0)),

+ 102 - 1
backend/tests/unit/test_usage_tracker.py

@@ -5,7 +5,7 @@ AMS remain% delta as fallback, per-layer gcode for partial prints,
 slot-to-tray mapping resolution, and notification variable formatting.
 """
 
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 
@@ -327,6 +327,107 @@ class TestOnPrintComplete:
 class TestTrackFrom3mf:
     """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
 
+    @pytest.mark.asyncio
+    async def test_prefers_live_assignment_when_reassigned_mid_print(self):
+        """If tray assignment changed during print, track usage on the new spool."""
+        spool_old = _make_spool(spool_id=1, label_weight=1000)
+        spool_new = _make_spool(spool_id=2, label_weight=1000)
+        archive = _make_archive(archive_id=80)
+
+        live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        started_at = datetime.now(timezone.utc)
+        live_assignment.created_at = started_at + timedelta(seconds=5)
+
+        # db: archive, queue_item(None), live assignment lookup, spool_new lookup
+        db = _mock_db_sequential([archive, None, live_assignment, spool_new])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=80,
+                status="completed",
+                print_name="MidPrintReassign",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                spool_assignments={(0, 0): spool_old.id},
+                print_started_at=started_at,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == spool_new.id
+
+    @pytest.mark.asyncio
+    async def test_keeps_snapshot_when_live_assignment_predates_print(self):
+        """If live assignment predates print start, preserve snapshot spool mapping."""
+        spool_old = _make_spool(spool_id=1, label_weight=1000)
+        archive = _make_archive(archive_id=81)
+
+        live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        started_at = datetime.now(timezone.utc)
+        live_assignment.created_at = started_at - timedelta(seconds=5)
+
+        # db: archive, queue_item(None), live assignment lookup, spool_old lookup
+        db = _mock_db_sequential([archive, None, live_assignment, spool_old])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=81,
+                status="completed",
+                print_name="SnapshotPreserved",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                spool_assignments={(0, 0): spool_old.id},
+                print_started_at=started_at,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == spool_old.id
+
     @pytest.mark.asyncio
     async def test_linear_fallback_for_partial_print(self):
         """Falls back to linear scaling when gcode layer data unavailable."""

+ 56 - 17
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -9,11 +9,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { renderHook, waitFor, act } from '@testing-library/react';
 import React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ToastProvider } from '../../contexts/ToastContext';
 
 // Track WebSocket instances created during tests
 let wsInstances: MockWebSocket[] = [];
 let originalWebSocket: typeof WebSocket;
 
+// Mock react-i18next BEFORE any modules that use it are imported
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string, options?: Record<string, unknown>) => {
+      if (key === 'printers.toast.missingSpoolAssignment' && options) {
+        const { printer, slots } = options as { printer: string; slots: string };
+        return `Missing assignments for ${printer}: ${slots}`;
+      }
+      return key;
+    },
+    i18n: {},
+  }),
+}));
+
 // Enhanced MockWebSocket that tracks instances
 class MockWebSocket {
   static readonly CONNECTING = 0;
@@ -77,13 +92,17 @@ function createTestQueryClient() {
   });
 }
 
-// Wrapper with QueryClient for hook testing
+// Wrapper with QueryClient and ToastProvider for hook testing
 function createWrapper(queryClient: QueryClient) {
   return function Wrapper({ children }: { children: React.ReactNode }) {
     return React.createElement(
-      QueryClientProvider,
-      { client: queryClient },
-      children
+      ToastProvider,
+      {},
+      React.createElement(
+        QueryClientProvider,
+        { client: queryClient },
+        children
+      )
     );
   };
 }
@@ -99,6 +118,7 @@ describe('useWebSocket hook', () => {
     vi.clearAllMocks();
     wsInstances = [];
     queryClient = createTestQueryClient();
+
     // Save original and install mock
     originalWebSocket = globalThis.WebSocket;
     globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
@@ -164,8 +184,6 @@ describe('useWebSocket hook', () => {
 
   describe('hook connection', () => {
     it('connects to WebSocket on mount', async () => {
-      // Reset module cache to get fresh import with our mock
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       renderHook(() => useWebSocket(), {
@@ -178,7 +196,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('reports connected state when WebSocket opens', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const { result } = renderHook(() => useWebSocket(), {
@@ -260,7 +277,6 @@ describe('useWebSocket hook', () => {
         cb(0);
         return 0;
       });
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -303,7 +319,6 @@ describe('useWebSocket hook', () => {
         cb(0);
         return 0;
       });
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -345,7 +360,6 @@ describe('useWebSocket hook', () => {
         cb(0);
         return 0;
       });
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -380,8 +394,39 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
     });
 
+    it('handles missing_spool_assignment message without error', async () => {
+      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+        cb(0);
+        return 0;
+      });
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = getLatestWs()!;
+      act(() => {
+        ws.open();
+      });
+
+      // This test verifies that the hook properly handles missing_spool_assignment messages
+      // without throwing an error. The actual toast display is tested via the UI.
+      expect(() => {
+        act(() => {
+          ws.simulateMessage({
+            type: 'missing_spool_assignment',
+            printer_id: 7,
+            printer_name: 'Printer B',
+            missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
+          });
+        });
+      }).not.toThrow();
+
+      vi.unstubAllGlobals();
+    });
+
     it('ignores pong messages without error', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -409,7 +454,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('handles malformed JSON gracefully', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       renderHook(() => useWebSocket(), {
@@ -438,7 +482,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('handles unknown message types gracefully', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -470,7 +513,6 @@ describe('useWebSocket hook', () => {
 
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const { result } = renderHook(() => useWebSocket(), {
@@ -494,7 +536,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('does not send when disconnected', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const { result } = renderHook(() => useWebSocket(), {
@@ -516,7 +557,6 @@ describe('useWebSocket hook', () => {
   describe('reconnection', () => {
     it('reconnects after connection closes', async () => {
       vi.useFakeTimers();
-      vi.resetModules();
 
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
@@ -551,7 +591,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('cleans up on unmount', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const { unmount } = renderHook(() => useWebSocket(), {

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

@@ -1469,6 +1469,7 @@ export interface NotificationProvider {
   on_print_failed: boolean;
   on_print_stopped: boolean;
   on_print_progress: boolean;
+  on_print_missing_spool_assignment: boolean;
   // Printer status events
   on_printer_offline: boolean;
   on_printer_error: boolean;
@@ -1523,6 +1524,7 @@ export interface NotificationProviderCreate {
   on_print_failed?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
+  on_print_missing_spool_assignment?: boolean;
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
@@ -1570,6 +1572,7 @@ export interface NotificationProviderUpdate {
   on_print_failed?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
+  on_print_missing_spool_assignment?: boolean;
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_error?: boolean;

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_first_layer_complete && (
               <span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
             )}
+            {provider.on_print_missing_spool_assignment && (
+              <span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
+            )}
             {provider.quiet_hours_enabled && (
               <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
                 <Moon className="w-3 h-3" />
@@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.missingSpoolAssignmentDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_print_missing_spool_assignment ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.printFailed')}</p>
                   <Toggle

+ 4 - 1
frontend/src/contexts/ToastContext.tsx

@@ -6,6 +6,8 @@ import { formatFileSize } from '../utils/file';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
+type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;
+
 interface Toast {
   id: string;
   message: string;
@@ -38,7 +40,7 @@ interface DispatchToastData {
 
 interface ToastContextType {
   showToast: (message: string, type?: ToastType) => void;
-  showPersistentToast: (id: string, message: string, type?: ToastType) => void;
+  showPersistentToast: ShowPersistentToast;
   dismissToast: (id: string) => void;
 }
 
@@ -460,6 +462,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     return () => window.removeEventListener('background-dispatch', onDispatchEvent);
   }, [t]);
 
+
   return (
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
       {children}

+ 37 - 1
frontend/src/hooks/useWebSocket.ts

@@ -1,10 +1,14 @@
 import { useQueryClient } from '@tanstack/react-query';
 import { useCallback, useEffect, useRef, useState } from 'react';
+import { useToast } from '../contexts/ToastContext';
+import { useTranslation } from 'react-i18next';
 
 interface WebSocketMessage {
   type: string;
   printer_id?: number;
   data?: Record<string, unknown>;
+  printer_name?: string;
+  missing_slots?: Array<{ slot?: string }>;
 }
 
 export function useWebSocket() {
@@ -12,6 +16,9 @@ export function useWebSocket() {
   const reconnectTimeoutRef = useRef<number | null>(null);
   const queryClient = useQueryClient();
   const [isConnected, setIsConnected] = useState(false);
+  const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
+  const { showToast } = useToast();
+  const { t } = useTranslation();
 
   // Debounce invalidations to prevent rapid re-render cascades
   const pendingInvalidations = useRef<Set<string>>(new Set());
@@ -195,6 +202,35 @@ export function useWebSocket() {
         }
         break;
 
+      case 'missing_spool_assignment': {
+        if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
+          break;
+        }
+
+        const missingSlotLabels = message.missing_slots
+          .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
+          .filter((slot) => slot.length > 0);
+
+        if (missingSlotLabels.length === 0) {
+          lastMissingSpoolWarningRef.current.delete(message.printer_id);
+          break;
+        }
+
+        const signature = missingSlotLabels.join('|');
+        if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
+          break;
+        }
+        lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
+
+        const printerName = message.printer_name || `Printer ${message.printer_id}`;
+        const toastMsg = t('printers.toast.missingSpoolAssignment', {
+          printer: printerName,
+          slots: missingSlotLabels.join(', '),
+        });
+        showToast(toastMsg, 'warning');
+        break;
+      }
+
       case 'print_complete':
         // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
         // The printer_status websocket messages will naturally update the status
@@ -301,7 +337,7 @@ export function useWebSocket() {
         debouncedInvalidate('spoolbuddy-update-check');
         break;
     }
-  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
+  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
 
   // Keep the ref updated with latest handleMessage
   useEffect(() => {

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: 'Drucker gelöscht',
+      missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
       printerAdded: 'Drucker hinzugefügt',
       printerUpdated: 'Drucker aktualisiert',
       failedToDelete: 'Drucker konnte nicht gelöscht werden',
@@ -3821,6 +3822,8 @@ export default {
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
+    missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
+    missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
     printFailed: 'Druck fehlgeschlagen',
     printStopped: 'Druck gestoppt',
     progressMilestones: 'Fortschrittsmeilensteine',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: 'Printer deleted',
+      missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
       printerAdded: 'Printer added',
       printerUpdated: 'Printer updated',
       failedToDelete: 'Failed to delete printer',
@@ -3826,6 +3827,8 @@ export default {
     bedCooledDescription: 'Bed cooled below threshold after print',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
+    missingSpoolAssignmentLabel: 'Missing Spool Assignment',
+    missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
     printFailed: 'Print Failed',
     printStopped: 'Print Stopped',
     progressMilestones: 'Progress Milestones',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: 'Imprimante supprimée',
+      missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
       printerAdded: 'Imprimante ajoutée',
       printerUpdated: 'Imprimante mise à jour',
       failedToDelete: 'Échec de la suppression',
@@ -3813,6 +3814,8 @@ export default {
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
+    missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
+    missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
     printFailed: 'Impression échouée',
     printStopped: 'Impression arrêtée',
     progressMilestones: 'Jalons de progression',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: 'Stampante eliminata',
+      missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
       printerAdded: 'Stampante aggiunta',
       printerUpdated: 'Stampante aggiornata',
       failedToDelete: 'Impossibile eliminare stampante',
@@ -3812,6 +3813,8 @@ export default {
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
+    missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
+    missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
     printFailed: 'Stampa fallita',
     printStopped: 'Stampa interrotta',
     progressMilestones: 'Traguardi di avanzamento',

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

@@ -264,6 +264,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: 'プリンターを削除しました',
+      missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
       printerAdded: 'プリンターを追加しました',
       printerUpdated: 'プリンターを更新しました',
       failedToDelete: 'プリンターの削除に失敗しました',
@@ -3825,6 +3826,8 @@ export default {
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
+    missingSpoolAssignmentLabel: 'スプール割り当て不足',
+    missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     printFailed: '印刷失敗',
     printStopped: '印刷停止',
     progressMilestones: '進捗マイルストーン',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: 'Impressora excluída',
+      missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
       printerAdded: 'Impressora adicionada',
       printerUpdated: 'Impressora atualizada',
       failedToDelete: 'Falha ao excluir impressora',
@@ -3812,6 +3813,8 @@ export default {
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
+    missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
+    missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
     printFailed: 'Impressão Falhou',
     printStopped: 'Impressão Parada',
     progressMilestones: 'Marcos de Progresso',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     toast: {
       printerDeleted: '打印机已删除',
+      missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
       printerAdded: '打印机已添加',
       printerUpdated: '打印机已更新',
       failedToDelete: '删除打印机失败',
@@ -3812,6 +3813,8 @@ export default {
     bedCooledDescription: '打印后热床温度降至阈值以下',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
+    missingSpoolAssignmentLabel: '缺少料卷分配',
+    missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     printFailed: '打印失败',
     printStopped: '打印已停止',
     progressMilestones: '进度里程碑',