diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index baf33efe5..19d9c76ab 100644 --- a/backend/app/api/routes/notifications.py +++ b/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, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 53ce76387..7bc6af208 100644 --- a/backend/app/core/database.py +++ b/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( diff --git a/backend/app/core/websocket.py b/backend/app/core/websocket.py index bb6b5e387..cc9da3080 100644 --- a/backend/app/core/websocket.py +++ b/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() diff --git a/backend/app/main.py b/backend/app/main.py index ff3bc9106..da2de2e5a 100644 --- a/backend/app/main.py +++ b/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) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 8fb06558b..03a7655cf 100644 --- a/backend/app/models/notification.py +++ b/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) diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index 33b3c99c1..d7972ae28 100644 --- a/backend/app/models/notification_template.py +++ b/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", diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index bfe551a93..3c7322735 100644 --- a/backend/app/schemas/notification.py +++ b/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 diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py index 2e6f3bb8f..e52b4ee29 100644 --- a/backend/app/schemas/notification_template.py +++ b/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", diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 1f671b732..5844773a3 100644 --- a/backend/app/services/notification_service.py +++ b/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) diff --git a/backend/app/services/spool_assignment_notifications.py b/backend/app/services/spool_assignment_notifications.py new file mode 100644 index 000000000..7c42792ab --- /dev/null +++ b/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) diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 93ea6bc80..aa550e543 100644 --- a/backend/app/services/usage_tracker.py +++ b/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)) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5a39c7742..b2868880f 100644 --- a/backend/tests/conftest.py +++ b/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, diff --git a/backend/tests/integration/test_notifications_api.py b/backend/tests/integration/test_notifications_api.py index c67e09878..3316f9f66 100644 --- a/backend/tests/integration/test_notifications_api.py +++ b/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.""" diff --git a/backend/tests/unit/services/test_spool_assignment_notifications.py b/backend/tests/unit/services/test_spool_assignment_notifications.py new file mode 100644 index 000000000..aeedfb89b --- /dev/null +++ b/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"}] diff --git a/backend/tests/unit/services/test_usage_tracker.py b/backend/tests/unit/services/test_usage_tracker.py index aad8cd890..8f14dfe12 100644 --- a/backend/tests/unit/services/test_usage_tracker.py +++ b/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)), diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py index 96de98d93..1d057efa9 100644 --- a/backend/tests/unit/test_usage_tracker.py +++ b/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.""" diff --git a/frontend/src/__tests__/hooks/useWebSocket.test.ts b/frontend/src/__tests__/hooks/useWebSocket.test.ts index 0d04d2977..479ea8a5e 100644 --- a/frontend/src/__tests__/hooks/useWebSocket.test.ts +++ b/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) => { + 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(), { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4326e9efc..91480866e 100644 --- a/frontend/src/api/client.ts +++ b/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; diff --git a/frontend/src/components/NotificationProviderCard.tsx b/frontend/src/components/NotificationProviderCard.tsx index 99299d743..baae03f5e 100644 --- a/frontend/src/components/NotificationProviderCard.tsx +++ b/frontend/src/components/NotificationProviderCard.tsx @@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi {provider.on_first_layer_complete && ( {t('notifications.firstLayer')} )} + {provider.on_print_missing_spool_assignment && ( + {t('notifications.missingSpoolAssignmentLabel')} + )} {provider.quiet_hours_enabled && ( @@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi /> +
+
+

{t('notifications.missingSpoolAssignmentLabel')}

+

{t('notifications.missingSpoolAssignmentDescription')}

+
+ updateMutation.mutate({ on_print_missing_spool_assignment: checked })} + /> +
+

{t('notifications.printFailed')}

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 ( {children} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 324edb506..b266c613f 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/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; + printer_name?: string; + missing_slots?: Array<{ slot?: string }>; } export function useWebSocket() { @@ -12,6 +16,9 @@ export function useWebSocket() { const reconnectTimeoutRef = useRef(null); const queryClient = useQueryClient(); const [isConnected, setIsConnected] = useState(false); + const lastMissingSpoolWarningRef = useRef>(new Map()); + const { showToast } = useToast(); + const { t } = useTranslation(); // Debounce invalidations to prevent rapid re-render cascades const pendingInvalidations = useRef>(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(() => { diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 745f42ab2..fa064dfb6 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5b105b163..ba9775756 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index c2444af50..1f6346078 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 81105bdfa..3380e81f7 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index dfd6af98a..0691448b1 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: '進捗マイルストーン', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index aa20b5b68..3166d7f23 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 907e568e8..5fefdaa20 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '进度里程碑',