Merge pull request #356 from aneopsy/0.2.0b

Queue Page - Add weight information
This commit is contained in:
MartinNYHC
2026-02-16 08:42:32 +01:00
committed by GitHub
11 changed files with 262 additions and 18 deletions
+11
View File
@@ -31,6 +31,7 @@ from backend.app.schemas.print_queue import (
)
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.threemf_tools import extract_filament_usage_from_3mf
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_thumbnail = item.archive.thumbnail_path
response.print_time_seconds = item.archive.print_time_seconds
response.filament_used_grams = item.archive.filament_used_grams
if item.plate_id:
archive_path = settings.base_dir / item.archive.file_path
if archive_path.exists():
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:
response.print_time_seconds = plate_time
if plate_weight > 0:
response.filament_used_grams = plate_weight
if item.library_file:
response.library_file_name = (
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
if not item.archive and item.library_file.file_metadata:
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:
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
if library_file_path.exists():
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:
response.print_time_seconds = plate_time
if plate_weight > 0:
response.filament_used_grams = plate_weight
if item.printer:
response.printer_name = item.printer.name
return response
+1
View File
@@ -97,6 +97,7 @@ class PrintQueueItemResponse(BaseModel):
library_file_thumbnail: str | None = None # Thumbnail of library file
printer_name: str | None = None
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)
created_by_id: int | None = None
+51 -17
View File
@@ -320,7 +320,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
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.
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:
file_path: Path to the 3MF file
plate_id: Optional plate index to filter for (for multi-plate files)
Returns:
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()
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:
pass # Return whatever usage data was collected before the error
+161
View File
@@ -4,15 +4,27 @@ Tests G-code parsing, filament length-to-weight conversion,
and cumulative layer usage lookup.
"""
import io
import math
import zipfile
from backend.app.utils.threemf_tools import (
extract_filament_usage_from_3mf,
get_cumulative_usage_at_layer,
mm_to_grams,
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:
"""Tests for parse_gcode_layer_filament_usage()."""
@@ -247,3 +259,152 @@ class TestGetCumulativeUsageAtLayer:
"""Target layer 0."""
data = {0: {0: 10.0}, 1: {0: 20.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
View File
@@ -1236,6 +1236,7 @@ export interface PrintQueueItem {
library_file_thumbnail?: string | null;
printer_name?: string | null;
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)
created_by_id?: number | null;
created_by_username?: string | null;
+1
View File
@@ -762,6 +762,7 @@ export default {
printing: 'Druckt',
queued: 'In Warteschlange',
totalTime: 'Gesamte Wartezeit',
totalWeight: 'Gesamtgewicht der Warteschlange',
history: 'Verlauf',
},
// Filters
+1
View File
@@ -762,6 +762,7 @@ export default {
printing: 'Printing',
queued: 'Queued',
totalTime: 'Total Queue Time',
totalWeight: 'Total Queue Weight',
history: 'History',
},
// Filters
+1
View File
@@ -762,6 +762,7 @@ export default {
printing: 'Impressions',
queued: 'En attente',
totalTime: 'Temps total estimé',
totalWeight: 'Poids total estimé',
history: 'Historique',
},
// Filters
+1
View File
@@ -749,6 +749,7 @@ export default {
printing: 'In stampa',
queued: 'In coda',
totalTime: 'Tempo totale coda',
totalWeight: 'Peso totale della coda',
history: 'Cronologia',
},
// Filters
+1
View File
@@ -830,6 +830,7 @@ export default {
queued: 'キュー中',
history: '履歴',
totalTime: 'キュー合計時間',
totalWeight: 'キュー合計重量',
},
filter: {
allPrinters: 'すべてのプリンター',
+32 -1
View File
@@ -47,6 +47,7 @@ import {
Square,
User,
Pause,
Weight,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -66,6 +67,11 @@ function formatDuration(seconds: number | null | undefined): string {
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 {
if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
const date = parseUTCDate(dateString);
@@ -450,6 +456,12 @@ function SortableQueueItem({
{formatDuration(item.print_time_seconds)}
</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 && (
<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" />
@@ -894,6 +906,11 @@ export function QueuePage() {
return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
}, [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 { active, over } = event;
if (!over || active.id === over.id) return;
@@ -925,7 +942,7 @@ export function QueuePage() {
</div>
{/* 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">
<CardContent className="p-4">
<div className="flex items-center gap-3">
@@ -968,6 +985,20 @@ export function QueuePage() {
</CardContent>
</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">
<CardContent className="p-4">
<div className="flex items-center gap-3">