mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 04:31:28 +02:00
Merge pull request #356 from aneopsy/0.2.0b
Queue Page - Add weight information
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] == ""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -762,6 +762,7 @@ export default {
|
||||
printing: 'Druckt',
|
||||
queued: 'In Warteschlange',
|
||||
totalTime: 'Gesamte Wartezeit',
|
||||
totalWeight: 'Gesamtgewicht der Warteschlange',
|
||||
history: 'Verlauf',
|
||||
},
|
||||
// Filters
|
||||
|
||||
@@ -762,6 +762,7 @@ export default {
|
||||
printing: 'Printing',
|
||||
queued: 'Queued',
|
||||
totalTime: 'Total Queue Time',
|
||||
totalWeight: 'Total Queue Weight',
|
||||
history: 'History',
|
||||
},
|
||||
// Filters
|
||||
|
||||
@@ -762,6 +762,7 @@ export default {
|
||||
printing: 'Impressions',
|
||||
queued: 'En attente',
|
||||
totalTime: 'Temps total estimé',
|
||||
totalWeight: 'Poids total estimé',
|
||||
history: 'Historique',
|
||||
},
|
||||
// Filters
|
||||
|
||||
@@ -749,6 +749,7 @@ export default {
|
||||
printing: 'In stampa',
|
||||
queued: 'In coda',
|
||||
totalTime: 'Tempo totale coda',
|
||||
totalWeight: 'Peso totale della coda',
|
||||
history: 'Cronologia',
|
||||
},
|
||||
// Filters
|
||||
|
||||
@@ -830,6 +830,7 @@ export default {
|
||||
queued: 'キュー中',
|
||||
history: '履歴',
|
||||
totalTime: 'キュー合計時間',
|
||||
totalWeight: 'キュー合計重量',
|
||||
},
|
||||
filter: {
|
||||
allPrinters: 'すべてのプリンター',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user