mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Edward's diagnosis was exact: the manual /print-queue/ POST extracts filament requirements from the 3MF and writes required_filament_types + filament_overrides + ams_mapping onto the queue item, but the VP queue-mode write path skipped all of that. Net effect: scheduler reached its model-only-matching fallback and auto-dispatched onto whatever printer was free regardless of loaded colour. Extract the scheduler's existing _get_filament_requirements 3MF parser into a shared helper so the VP path can reuse it. VP's _add_to_print_queue now populates required_filament_types unconditionally (cheap; helps the scheduler reject obvious type mismatches) and writes filament_overrides with force_color_match: true per consumed slot when a new per-VP queue_force_color_match toggle is on. Default off to preserve current behaviour for upgraders. UI: new toggle on VirtualPrinterCard, mode-gated to print_queue, mirroring the existing auto-dispatch toggle. i18n: en + de translated, other 6 locales seeded with English copy. Schema: one nullable column on virtual_printers (queue_force_color_match BOOLEAN, default 0/FALSE). 11 new backend tests (8 for the extracted parser, 3 for the VP write path) + 6 new frontend tests (toggle render gating, default state, click posts queue_force_color_match in update body). Existing scheduler tests pass against the refactored helper. README, CHANGELOG, website features page, and wiki virtual-printer page all updated.
114 lines
4.4 KiB
Python
114 lines
4.4 KiB
Python
"""Parse per-slot filament requirements out of a 3MF file.
|
|
|
|
The scheduler used to own this logic (`PrintScheduler._get_filament_requirements`)
|
|
because it ran during dispatch decisions. Extracted here so the VP queue-mode
|
|
write path can use the same parser to populate `filament_overrides` /
|
|
`required_filament_types` at upload time (#1188 — Bambuddy was creating queue
|
|
items with no filament fields, which made the scheduler fall through to
|
|
model-only matching and dispatch onto whatever printer happened to be free
|
|
regardless of loaded colour).
|
|
|
|
The shape returned here matches the `filament_overrides` JSON shape the
|
|
scheduler validates against, minus the `force_color_match` flag — callers
|
|
add that themselves based on their own setting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
|
|
"""Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
|
|
|
|
Args:
|
|
file_path: Path to the 3MF.
|
|
plate_id: When set, only return filaments used on that plate. When
|
|
None, return every filament with `used_g > 0` across the file.
|
|
|
|
Returns:
|
|
Sorted list (by `slot_id`) of filament dicts. Empty list when the
|
|
3MF is unreadable, missing `Metadata/slice_info.config`, or has no
|
|
filaments matching the plate filter — callers treat that as "no
|
|
requirements" rather than an error so a malformed 3MF doesn't break
|
|
the upload path.
|
|
"""
|
|
if not file_path.exists():
|
|
return []
|
|
|
|
filaments: list[dict] = []
|
|
try:
|
|
with zipfile.ZipFile(file_path, "r") as zf:
|
|
if "Metadata/slice_info.config" not in zf.namelist():
|
|
return []
|
|
|
|
content = zf.read("Metadata/slice_info.config").decode()
|
|
root = ET.fromstring(content) # noqa: S314 # nosec B314
|
|
|
|
if plate_id is not None:
|
|
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:
|
|
_collect_filaments(plate_elem, filaments)
|
|
break
|
|
else:
|
|
_collect_filaments(root, filaments)
|
|
|
|
filaments.sort(key=lambda x: x["slot_id"])
|
|
|
|
# Dual-nozzle printers (H2D / X2D) — annotate which extruder each
|
|
# slot is fed into. Empty mapping for single-nozzle printers, in
|
|
# which case we just don't add the key.
|
|
nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
|
|
if nozzle_mapping:
|
|
for filament in filaments:
|
|
filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
|
|
except Exception as e:
|
|
logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
|
|
return []
|
|
|
|
return filaments
|
|
|
|
|
|
def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
|
|
"""Walk every `./filament` child under `parent` and append normalised
|
|
entries to `into`. Skips filaments with `used_g <= 0` (slot present in
|
|
the slicer config but not consumed by this plate)."""
|
|
for filament_elem in parent.findall("./filament"):
|
|
filament_id = filament_elem.get("id")
|
|
if not filament_id:
|
|
continue
|
|
try:
|
|
used_grams = float(filament_elem.get("used_g", "0"))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if used_grams <= 0:
|
|
continue
|
|
try:
|
|
slot_id = int(filament_id)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
into.append(
|
|
{
|
|
"slot_id": slot_id,
|
|
"type": filament_elem.get("type", ""),
|
|
"color": filament_elem.get("color", ""),
|
|
"tray_info_idx": filament_elem.get("tray_info_idx", ""),
|
|
"used_grams": round(used_grams, 1),
|
|
}
|
|
)
|