Quellcode durchsuchen

Merge pull request #356 from aneopsy/0.2.0b

Queue Page - Add weight information
MartinNYHC vor 6 Monaten
Ursprung
Commit
df839bb762

+ 11 - 0
backend/app/api/routes/print_queue.py

@@ -31,6 +31,7 @@ from backend.app.schemas.print_queue import (
 )
 )
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -205,12 +206,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         response.print_time_seconds = item.archive.print_time_seconds
+        response.filament_used_grams = item.archive.filament_used_grams
         if item.plate_id:
         if item.plate_id:
             archive_path = settings.base_dir / item.archive.file_path
             archive_path = settings.base_dir / item.archive.file_path
             if archive_path.exists():
             if archive_path.exists():
                 plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
                 plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
+                if plate_weight > 0:
+                    response.filament_used_grams = plate_weight
     if item.library_file:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -221,13 +226,19 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # Get print time from library file metadata if no archive
         # Get print time from library file metadata if no archive
         if not item.archive and item.library_file.file_metadata:
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
+            response.filament_used_grams = item.library_file.file_metadata.get("filament_used_grams")
         if item.plate_id:
         if item.plate_id:
             lib_path = Path(item.library_file.file_path)
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
             if library_file_path.exists():
                 plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
                 plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
+                plate_weight = sum(
+                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
+                )
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
+                if plate_weight > 0:
+                    response.filament_used_grams = plate_weight
     if item.printer:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response

+ 1 - 0
backend/app/schemas/print_queue.py

@@ -97,6 +97,7 @@ class PrintQueueItemResponse(BaseModel):
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None
     printer_name: str | None = None
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
+    filament_used_grams: float | None = None  # Estimated print weight from archive or library file
 
 
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: int | None = None
     created_by_id: int | None = None

+ 51 - 17
backend/app/utils/threemf_tools.py

@@ -320,7 +320,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         return None
         return None
 
 
 
 
-def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
+def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
     """Extract per-filament total usage from 3MF slice_info.config.
 
 
     This extracts the slicer-estimated total usage per filament slot,
     This extracts the slicer-estimated total usage per filament slot,
@@ -328,6 +328,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
 
 
     Args:
     Args:
         file_path: Path to the 3MF file
         file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
 
 
     Returns:
     Returns:
         List of filament usage dictionaries:
         List of filament usage dictionaries:
@@ -342,22 +343,55 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
             content = zf.read("Metadata/slice_info.config").decode()
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
             root = ET.fromstring(content)
 
 
-            for f in root.findall(".//filament"):
-                filament_id = f.get("id")
-                used_g = f.get("used_g", "0")
-                try:
-                    used_amount = float(used_g)
-                    if filament_id:
-                        filament_usage.append(
-                            {
-                                "slot_id": int(filament_id),
-                                "used_g": used_amount,
-                                "type": f.get("type", ""),
-                                "color": f.get("color", ""),
-                            }
-                        )
-                except (ValueError, TypeError):
-                    pass  # Skip filament entries with unparseable usage values
+            if plate_id is not None:
+                # Find the plate element with matching index
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for f in plate_elem.findall("filament"):
+                            filament_id = f.get("id")
+                            used_g = f.get("used_g", "0")
+                            try:
+                                used_amount = float(used_g)
+                                if filament_id:
+                                    filament_usage.append(
+                                        {
+                                            "slot_id": int(filament_id),
+                                            "used_g": used_amount,
+                                            "type": f.get("type", ""),
+                                            "color": f.get("color", ""),
+                                        }
+                                    )
+                            except (ValueError, TypeError):
+                                pass
+                        break
+            else:
+                # No plate_id specified - extract all filaments
+                for f in root.findall(".//filament"):
+                    filament_id = f.get("id")
+                    used_g = f.get("used_g", "0")
+                    try:
+                        used_amount = float(used_g)
+                        if filament_id:
+                            filament_usage.append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "used_g": used_amount,
+                                    "type": f.get("type", ""),
+                                    "color": f.get("color", ""),
+                                }
+                            )
+                    except (ValueError, TypeError):
+                        pass  # Skip filament entries with unparseable usage values
+
     except Exception:
     except Exception:
         pass  # Return whatever usage data was collected before the error
         pass  # Return whatever usage data was collected before the error
 
 

+ 161 - 0
backend/tests/unit/test_threemf_tools.py

@@ -4,15 +4,27 @@ Tests G-code parsing, filament length-to-weight conversion,
 and cumulative layer usage lookup.
 and cumulative layer usage lookup.
 """
 """
 
 
+import io
 import math
 import math
+import zipfile
 
 
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    extract_filament_usage_from_3mf,
     get_cumulative_usage_at_layer,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
     parse_gcode_layer_filament_usage,
 )
 )
 
 
 
 
+def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
+    """Create a mock 3MF file (ZIP) with slice_info.config content."""
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", slice_info_content)
+    buffer.seek(0)
+    return buffer
+
+
 class TestParseGcodeLayerFilamentUsage:
 class TestParseGcodeLayerFilamentUsage:
     """Tests for parse_gcode_layer_filament_usage()."""
     """Tests for parse_gcode_layer_filament_usage()."""
 
 
@@ -247,3 +259,152 @@ class TestGetCumulativeUsageAtLayer:
         """Target layer 0."""
         """Target layer 0."""
         data = {0: {0: 10.0}, 1: {0: 20.0}}
         data = {0: {0: 10.0}, 1: {0: 20.0}}
         assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
         assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
+
+
+class TestExtractFilamentUsageFrom3mf:
+    """Tests for extract_filament_usage_from_3mf function."""
+
+    def test_extract_single_filament(self, tmp_path):
+        """Test extracting a single filament."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 1
+        assert result[0]["used_g"] == 50.5
+        assert result[0]["type"] == "PLA"
+        assert result[0]["color"] == "#FF0000"
+
+    def test_extract_multiple_filaments(self, tmp_path):
+        """Test extracting multiple filaments."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
+            <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 3
+        assert result[0]["slot_id"] == 1
+        assert result[1]["slot_id"] == 2
+        assert result[2]["slot_id"] == 3
+
+    def test_extract_filament_with_plate_id(self, tmp_path):
+        """Test extracting filament for a specific plate."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
+            </plate>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path, plate_id=2)
+
+        assert len(result) == 1
+        assert result[0]["used_g"] == 75.0
+        assert result[0]["type"] == "PETG"
+
+    def test_missing_slice_info_returns_empty(self, tmp_path):
+        """Test that missing slice_info.config returns empty list."""
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as zf:
+            zf.writestr("other_file.txt", "content")
+        buffer.seek(0)
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(buffer.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_invalid_file_returns_empty(self, tmp_path):
+        """Test that invalid file returns empty list."""
+        file_path = tmp_path / "invalid.3mf"
+        file_path.write_text("not a zip file")
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_nonexistent_file_returns_empty(self, tmp_path):
+        """Test that nonexistent file returns empty list."""
+        file_path = tmp_path / "nonexistent.3mf"
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_filament_without_id_is_skipped(self, tmp_path):
+        """Test that filament without id is skipped."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament used_g="50.5" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 2
+
+    def test_invalid_used_g_is_skipped(self, tmp_path):
+        """Test that filament with invalid used_g is skipped."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 2
+
+    def test_missing_optional_fields(self, tmp_path):
+        """Test that missing type and color default to empty string."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["type"] == ""
+        assert result[0]["color"] == ""

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

@@ -1236,6 +1236,7 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
+  filament_used_grams?: number | null;  // Estimated print weight from archive or library file
   // User tracking (Issue #206)
   // User tracking (Issue #206)
   created_by_id?: number | null;
   created_by_id?: number | null;
   created_by_username?: string | null;
   created_by_username?: string | null;

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

@@ -762,6 +762,7 @@ export default {
       printing: 'Druckt',
       printing: 'Druckt',
       queued: 'In Warteschlange',
       queued: 'In Warteschlange',
       totalTime: 'Gesamte Wartezeit',
       totalTime: 'Gesamte Wartezeit',
+      totalWeight: 'Gesamtgewicht der Warteschlange',
       history: 'Verlauf',
       history: 'Verlauf',
     },
     },
     // Filters
     // Filters

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

@@ -762,6 +762,7 @@ export default {
       printing: 'Printing',
       printing: 'Printing',
       queued: 'Queued',
       queued: 'Queued',
       totalTime: 'Total Queue Time',
       totalTime: 'Total Queue Time',
+      totalWeight: 'Total Queue Weight',
       history: 'History',
       history: 'History',
     },
     },
     // Filters
     // Filters

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

@@ -762,6 +762,7 @@ export default {
       printing: 'Impressions',
       printing: 'Impressions',
       queued: 'En attente',
       queued: 'En attente',
       totalTime: 'Temps total estimé',
       totalTime: 'Temps total estimé',
+      totalWeight: 'Poids total estimé',
       history: 'Historique',
       history: 'Historique',
     },
     },
     // Filters
     // Filters

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

@@ -749,6 +749,7 @@ export default {
       printing: 'In stampa',
       printing: 'In stampa',
       queued: 'In coda',
       queued: 'In coda',
       totalTime: 'Tempo totale coda',
       totalTime: 'Tempo totale coda',
+      totalWeight: 'Peso totale della coda',
       history: 'Cronologia',
       history: 'Cronologia',
     },
     },
     // Filters
     // Filters

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

@@ -830,6 +830,7 @@ export default {
       queued: 'キュー中',
       queued: 'キュー中',
       history: '履歴',
       history: '履歴',
       totalTime: 'キュー合計時間',
       totalTime: 'キュー合計時間',
+      totalWeight: 'キュー合計重量',
     },
     },
     filter: {
     filter: {
       allPrinters: 'すべてのプリンター',
       allPrinters: 'すべてのプリンター',

+ 32 - 1
frontend/src/pages/QueuePage.tsx

@@ -47,6 +47,7 @@ import {
   Square,
   Square,
   User,
   User,
   Pause,
   Pause,
+  Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -66,6 +67,11 @@ function formatDuration(seconds: number | null | undefined): string {
   return `${minutes}m`;
   return `${minutes}m`;
 }
 }
 
 
+function formatWeight(g: number, useKg = false): string {
+  if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
+  return `${Math.round(g)}g`;
+}
+
 function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
 function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
   if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
   if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
   const date = parseUTCDate(dateString);
   const date = parseUTCDate(dateString);
@@ -450,6 +456,12 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
                 {formatDuration(item.print_time_seconds)}
               </span>
               </span>
             )}
             )}
+            {item.filament_used_grams && (
+              <span className="flex items-center gap-1.5">
+                <Weight className="w-3.5 h-3.5" />
+                {formatWeight(item.filament_used_grams)}
+              </span>
+            )}
             {item.created_by_username && (
             {item.created_by_username && (
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
                 <User className="w-3.5 h-3.5" />
@@ -894,6 +906,11 @@ export function QueuePage() {
     return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
     return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
   }, [pendingItems]);
   }, [pendingItems]);
 
 
+  // Calculate total material weight
+  const totalWeight = useMemo(() => {
+    return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
+  }, [pendingItems]);
+
   const handleDragEnd = (event: DragEndEvent) => {
   const handleDragEnd = (event: DragEndEvent) => {
     const { active, over } = event;
     const { active, over } = event;
     if (!over || active.id === over.id) return;
     if (!over || active.id === over.id) return;
@@ -925,7 +942,7 @@ export function QueuePage() {
       </div>
       </div>
 
 
       {/* Summary Cards */}
       {/* Summary Cards */}
-      <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
+      <div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
           <CardContent className="p-4">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
@@ -968,6 +985,20 @@ export function QueuePage() {
           </CardContent>
           </CardContent>
         </Card>
         </Card>
 
 
+        <Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
+                <Weight className="w-5 h-5 text-purple-500" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
           <CardContent className="p-4">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">