mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
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.
This commit is contained in:
@@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
|
||||
|
||||
### Fixed
|
||||
- **Virtual Printer queue mode auto-dispatched onto the wrong colour when multiple compatible printers were available** ([#1188](https://github.com/maziggy/bambuddy/issues/1188), reported by @EdwardChamberlain) — Sending a sliced 3MF to a queue-mode VP via Orca / Studio with auto-dispatch on caused Bambuddy to schedule the job onto a printer of the right model but the wrong loaded filament: a print sliced for matte white PLA would land on a printer with no white loaded, and the printer would start the job using whatever was the closest available match. Edward's diagnosis was exact (`virtual_printer/manager.py:325-326`): the manual /api/v1/print-queue/ POST flow extracts the 3MF's per-slot filament requirements at queue-add time and writes `required_filament_types`, `filament_overrides`, and `ams_mapping` on the resulting `PrintQueueItem`, so the scheduler's color-match enforcement (`print_scheduler.py:512` — keys on `filament_overrides[].force_color_match === true`) actually runs. The VP queue-write path (`_add_to_print_queue`) skipped all of that and built a bare `PrintQueueItem` with only `printer_id`, `target_model`, `archive_id`, `plate_id`, `position`, `status`, `manual_start`. Net effect: the scheduler reached the model-only-matching fallback and accepted the first available printer of the target model regardless of loaded colour, exactly as he described. **Fix:** the scheduler's existing `_get_filament_requirements` 3MF parser is extracted into a shared helper (`backend/app/services/filament_requirements.py:extract_filament_requirements`) so the VP path can reuse it at upload time. The VP's `_add_to_print_queue` now calls that helper after archiving and populates `required_filament_types` unconditionally (cheap; helps the scheduler reject obvious type mismatches even without `force_color_match`); and writes `filament_overrides` with `force_color_match: true` per consumed slot when a new per-VP setting `queue_force_color_match` is on. Default is **off** to preserve current behaviour for upgraders — a fresh-install user who wants the bug-free behaviour flips the toggle once on the VP card; an existing user gets exactly the model-only-matching they had before until they opt in. Auto-dispatch onto the wrong material happens loudly enough that anyone affected can find the toggle. **Why default-off** rather than default-on: existing automation that relies on "send to queue VP, get printed somewhere" without caring about colour shouldn't silently start blocking on colour matching after an upgrade. The toggle has clear UI copy (`virtualPrinter.queueForceColorMatch`) explaining the trade-off. **Defence in depth:** a malformed or unparseable 3MF (e.g. fake bytes from a misconfigured upload tool) leaves both fields None and the scheduler falls back to model-only matching, matching pre-fix behaviour for the unhappy path. The scheduler itself is unchanged — it already handled `force_color_match` correctly when the field was populated; the bug was purely the VP path not populating it. **Schema:** one nullable column `virtual_printers.queue_force_color_match BOOLEAN DEFAULT 0/FALSE` (Postgres-safe) added via the existing `_safe_execute` migration pattern. **API:** `VirtualPrinterCreate` and `VirtualPrinterUpdate` Pydantic schemas + `_vp_to_dict` response shape carry `queue_force_color_match`, the create + update routes wire it through to the model, and `VirtualPrinterInstance` constructor + `multiVirtualPrinterApi` TypeScript client mirror the field. **UI:** new toggle on `VirtualPrinterCard` rendered only when `mode === 'print_queue'` (parallels the existing `auto_dispatch` toggle's mode-gating), with `pendingAction` state for the in-flight indicator. **i18n:** new `virtualPrinter.queueForceColorMatch.{title,description}` keys in all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 11 new tests: 8 in `test_filament_requirements.py` covering the extracted parser end-to-end (per-slot dicts, zero-use slots filtered, plate filtering, no-plate flat-walk fallback, unparseable / missing / config-less files, sorted output); 3 in `test_virtual_printer.py::TestVirtualPrinterInstance` covering the VP write path (setting-off → only `required_filament_types` populated; setting-on → `filament_overrides` populated with `force_color_match: true` per slot; unparseable 3MF → both fields None, no crash). Existing scheduler tests still pass against the refactored helper (verified end-to-end across the scheduler / virtual_printer / print_queue / filament test suites — 479 tests). Edward's "out of scope nice-to-have" suggestion of a "Requires Color Match" pill on queue cards is deferred to a follow-up so this PR stays scoped to his repro.
|
||||
|
||||
- **Slicing a library file via API key fails with "no Bambu Cloud session is stored" even when the key has cloud access** ([#1182](https://github.com/maziggy/bambuddy/issues/1182) follow-up, reported by @turulix) — Tim shipped the headless slicing pipeline #1182 was filed for, then hit a second wall: `GET /api/v1/cloud/settings` returned the cloud preset IDs correctly (the `/cloud/*` router-level gate from #1182 was doing its job), but `POST /api/v1/library/files/{id}/slice` with those IDs in the request body failed the slice job with `error_status: 400, error_detail: "Cloud preset selected for printer, but no Bambu Cloud session is stored. Sign in to Bambu Cloud and retry."` Cause: the `/cloud/*` fix routes the API key's owner User through `cloud_caller` (a router-level gate stashes the owner on `request.state.api_key_owner`, route-level deps pull it back out), but the slice route lives on `/library/*` — different router, no gate, so when the auth dep returned `None` for the API-keyed request the slice route passed `current_user_id=None` straight through to `_run_slicer_with_fallback` → `_resolve_cloud(db, user=None)` → `get_stored_token(db, None)`, which falls back to the auth-disabled global `Settings` table. That table is empty in auth-enabled deployments, so cloud preset resolution failed even though the key's owner User had a perfectly valid `cloud_token` on their User row. Fix is a new route-level dep `resolve_api_key_cloud_owner` in `cloud.py` that's permissive (returns the owner User if the key has `can_access_cloud=true`, otherwise None — never raises) so it can be safely added to non-`/cloud/*` routes without breaking the local-presets path: a request with an API key that lacks the cloud scope still slices fine against local presets, and only fails with the existing "no Bambu Cloud session" error if it actually selects a cloud preset. Wired into `POST /library/files/{id}/slice` (Tim's blocker) and `GET /slicer/presets` (the SliceModal preset dropdown source — same root cause, would have hit anyone using the UI through an API-keyed reverse proxy). Both routes now resolve the cloud-token owner via `current_user or api_key_cloud_owner` instead of `current_user.id if current_user else None`. The auth gate's None-return for API keys is unchanged — keeping that fix scoped to the routes that actually need cloud-token resolution prevents accidental scope creep into other routes that fence on `current_user is None`. 4 new integration tests in `test_api_key_cloud_access.py::TestSliceRouteCloudOwnerResolution` pin the dep contract: returns the owner for a key with `can_access_cloud=True` and a valid owner; returns None for an owned key without the cloud scope (so cloud presets still 400 cleanly, local presets still slice); returns None for legacy ownerless keys; no-op for JWT and anonymous callers.
|
||||
|
||||
- **Project cover photo thumbnail too small to recognise the print** ([#1155](https://github.com/maziggy/bambuddy/issues/1155) follow-up, reported by @smandon) — The 40×40 thumbnail @smandon's MakerWorld download workflow relied on for "is this the model I'm looking for?" wasn't readable at that size; he asked for either a larger thumbnail or a click-to-enlarge full preview. Enlarging the thumbnail itself would shift the card layout and cost the dense grid he chose to use for browsing many projects, so the fix keeps the 40×40 thumbnail and shows a portal-mounted 384×384 popover on hover. The popover renders the full image in `object-contain` so tall portrait MakerWorld photos aren't cropped to a square, has `pointer-events-none` so it can't intercept hover and create a flicker loop, and `z-[100]` so it stacks above every sibling card in the grid. **Why a portal:** ProjectCard carries `overflow-hidden` (for its rounded-corner clipping and the color accent bar), so an in-tree popover gets clipped by the card the moment it extends past the card's bounds — exactly the cut-off behaviour @smandon reported on the second iteration. Rendering via `createPortal(..., document.body)` escapes every ancestor clipping context, and `position: fixed` with measurements from `getBoundingClientRect()` keeps the popover pinned next to the thumbnail regardless of where the card sits in the grid. **Edge handling:** if the thumbnail is near the viewport's right edge the popover flips to the LEFT side of the thumbnail; vertical position is clamped so the popover never overflows the window top or bottom. The thumbnail's own `onClick` is `stopPropagation`'d so hovering the popover area never accidentally triggers the parent card's "open project" navigation. 2 new tests in `ProjectsPage.test.tsx` pin the contract: hovering mounts the popover at document.body level (not nested in the card — a future refactor that drops the portal would re-introduce the clipping bug, and the test catches that); leaving unmounts it; the popover img points at the same cover-image URL as the small thumbnail with `object-contain`; cards without a cover_image_filename never mount the portal-rendering component (so a hover doesn't flash an empty preview).
|
||||
|
||||
@@ -240,6 +240,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
|
||||
- Send prints directly from Bambu Studio/Orca Slicer
|
||||
- Configurable printer model (X1C, P1S, A1, H2D, etc.)
|
||||
- Archive mode, Review mode, Queue mode, or Proxy mode
|
||||
- Queue mode: optional **force-color-match** so the scheduler refuses to dispatch onto a printer with the wrong filament loaded
|
||||
- SSDP discovery (same LAN) or manual IP entry (VPN/remote)
|
||||
- Network interface override for multi-NIC/Docker/VPN setups
|
||||
- Secure TLS/MQTT/FTP communication
|
||||
|
||||
@@ -37,6 +37,7 @@ class VirtualPrinterCreate(BaseModel):
|
||||
access_code: str | None = None
|
||||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool = True
|
||||
queue_force_color_match: bool = False
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
||||
@@ -49,6 +50,7 @@ class VirtualPrinterUpdate(BaseModel):
|
||||
access_code: str | None = None
|
||||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool | None = None
|
||||
queue_force_color_match: bool | None = None
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
tailscale_disabled: bool | None = None
|
||||
@@ -90,6 +92,7 @@ def _vp_to_dict(vp, status: dict | None = None) -> dict:
|
||||
"serial": serial,
|
||||
"target_printer_id": vp.target_printer_id,
|
||||
"auto_dispatch": vp.auto_dispatch,
|
||||
"queue_force_color_match": vp.queue_force_color_match,
|
||||
"bind_ip": vp.bind_ip,
|
||||
"remote_interface_ip": vp.remote_interface_ip,
|
||||
"tailscale_disabled": vp.tailscale_disabled,
|
||||
@@ -209,6 +212,7 @@ async def create_virtual_printer(
|
||||
access_code=body.access_code,
|
||||
target_printer_id=body.target_printer_id,
|
||||
auto_dispatch=body.auto_dispatch,
|
||||
queue_force_color_match=body.queue_force_color_match,
|
||||
bind_ip=body.bind_ip,
|
||||
remote_interface_ip=body.remote_interface_ip,
|
||||
serial_suffix=new_suffix,
|
||||
@@ -331,6 +335,8 @@ async def update_virtual_printer(
|
||||
vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
|
||||
if body.auto_dispatch is not None:
|
||||
vp.auto_dispatch = body.auto_dispatch
|
||||
if body.queue_force_color_match is not None:
|
||||
vp.queue_force_color_match = body.queue_force_color_match
|
||||
if body.bind_ip is not None:
|
||||
vp.bind_ip = body.bind_ip
|
||||
if body.remote_interface_ip is not None:
|
||||
|
||||
@@ -637,6 +637,18 @@ async def run_migrations(conn):
|
||||
# Migration: Add ams_mapping column to print_queue for storing filament slot assignments
|
||||
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
|
||||
|
||||
# Migration: Add queue_force_color_match column to virtual_printers (#1188).
|
||||
# Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
|
||||
# from the 3MF onto the queue item's filament_overrides so the scheduler
|
||||
# refuses to dispatch onto a printer with the wrong filament loaded.
|
||||
# Default false to preserve current behaviour for upgraders.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(
|
||||
conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
|
||||
# Migration: Add target_parts_count column to projects for tracking total parts needed
|
||||
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ class VirtualPrinter(Base):
|
||||
auto_dispatch: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="true"
|
||||
) # print_queue mode: auto-start or manual
|
||||
queue_force_color_match: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="false"
|
||||
) # print_queue mode: pin per-slot type+color from the 3MF onto the queue
|
||||
# item so the scheduler refuses to dispatch onto a printer with the wrong
|
||||
# filament loaded (#1188).
|
||||
model: Mapped[str | None] = mapped_column(String(50), nullable=True) # SSDP model code (server mode)
|
||||
access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode)
|
||||
target_printer_id: Mapped[int | None] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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),
|
||||
}
|
||||
)
|
||||
@@ -4,11 +4,9 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -31,7 +29,6 @@ from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import printer_manager, supports_drying
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
from backend.app.utils.printer_models import normalize_printer_model
|
||||
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -825,17 +822,15 @@ class PrintScheduler:
|
||||
return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
|
||||
|
||||
async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
|
||||
"""Extract filament requirements from the source 3MF file.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
item: Queue item with archive_id or library_file_id
|
||||
|
||||
Returns:
|
||||
List of filament requirement dicts with slot_id, type, color, used_grams
|
||||
"""Resolve the queue item's source 3MF and parse the per-slot
|
||||
filament requirements out of it. Thin DB-resolver wrapper around
|
||||
``filament_requirements.extract_filament_requirements`` so the VP
|
||||
queue-mode write path (#1188) can reuse the same parser at upload
|
||||
time.
|
||||
"""
|
||||
file_path: Path | None = None
|
||||
from backend.app.services.filament_requirements import extract_filament_requirements
|
||||
|
||||
file_path: Path | None = None
|
||||
if item.archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
@@ -851,82 +846,7 @@ class PrintScheduler:
|
||||
if not file_path or not file_path.exists():
|
||||
return None
|
||||
|
||||
filaments = []
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
if "Metadata/slice_info.config" not in zf.namelist():
|
||||
return None
|
||||
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Check if plate_id is specified - use that plate's filaments
|
||||
plate_id = item.plate_id
|
||||
if plate_id:
|
||||
for plate_elem in root.findall("./plate"):
|
||||
plate_index = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "index":
|
||||
plate_index = int(meta.get("value", "0"))
|
||||
break
|
||||
if plate_index == plate_id:
|
||||
for filament_elem in plate_elem.findall("./filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
# tray_info_idx identifies the specific spool selected when slicing
|
||||
tray_info_idx = filament_elem.get("tray_info_idx", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"tray_info_idx": tray_info_idx,
|
||||
"used_grams": round(used_grams, 1),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip filament entry with unparseable usage data
|
||||
break
|
||||
else:
|
||||
# No plate_id - extract all filaments with used_g > 0
|
||||
for filament_elem in root.findall("./filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
# tray_info_idx identifies the specific spool selected when slicing
|
||||
tray_info_idx = filament_elem.get("tray_info_idx", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"tray_info_idx": tray_info_idx,
|
||||
"used_grams": round(used_grams, 1),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip filament entry with unparseable usage data
|
||||
|
||||
filaments.sort(key=lambda x: x["slot_id"])
|
||||
|
||||
# Enrich with nozzle mapping for dual-nozzle printers
|
||||
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: %s", e)
|
||||
return None
|
||||
|
||||
filaments = extract_filament_requirements(file_path, plate_id=item.plate_id)
|
||||
return filaments if filaments else None
|
||||
|
||||
def _build_loaded_filaments(self, status) -> list[dict]:
|
||||
|
||||
@@ -111,6 +111,7 @@ class VirtualPrinterInstance:
|
||||
target_printer_serial: str = "",
|
||||
target_printer_id: int | None = None,
|
||||
auto_dispatch: bool = True,
|
||||
queue_force_color_match: bool = False,
|
||||
bind_ip: str = "",
|
||||
remote_interface_ip: str = "",
|
||||
tailscale_disabled: bool = True,
|
||||
@@ -127,6 +128,7 @@ class VirtualPrinterInstance:
|
||||
self.target_printer_serial = target_printer_serial
|
||||
self.target_printer_id = target_printer_id
|
||||
self.auto_dispatch = auto_dispatch
|
||||
self.queue_force_color_match = queue_force_color_match
|
||||
self.bind_ip = bind_ip
|
||||
self.remote_interface_ip = remote_interface_ip
|
||||
self.tailscale_disabled = tailscale_disabled
|
||||
@@ -330,9 +332,12 @@ class VirtualPrinterInstance:
|
||||
return
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.filament_requirements import extract_filament_requirements
|
||||
|
||||
async with self._session_factory() as db:
|
||||
name_source = await get_setting(db, "virtual_printer_archive_name_source")
|
||||
@@ -355,6 +360,38 @@ class VirtualPrinterInstance:
|
||||
if not self.target_printer_id and self.model:
|
||||
target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
|
||||
plate_id = self._extract_plate_id(file_path)
|
||||
|
||||
# Parse the 3MF for per-slot filament requirements (#1188).
|
||||
# The manual /print-queue/ POST flow does this at queue-add
|
||||
# time; the VP path used to skip it, so the scheduler fell
|
||||
# through to model-only matching and dispatched onto whatever
|
||||
# printer happened to be free regardless of loaded colour.
|
||||
# required_filament_types is populated unconditionally — it's
|
||||
# cheap, lets the scheduler reject obvious mis-matches even
|
||||
# without force_color_match. filament_overrides only carries
|
||||
# force_color_match=True when the per-VP setting is on, so
|
||||
# upgraders keep the old behaviour by default.
|
||||
required_filament_types_json: str | None = None
|
||||
filament_overrides_json: str | None = None
|
||||
requirements = extract_filament_requirements(file_path, plate_id)
|
||||
if requirements:
|
||||
types = sorted({r["type"] for r in requirements if r.get("type")})
|
||||
if types:
|
||||
required_filament_types_json = json.dumps(types)
|
||||
if self.queue_force_color_match:
|
||||
overrides = [
|
||||
{
|
||||
"slot_id": r["slot_id"],
|
||||
"type": r.get("type", ""),
|
||||
"color": r.get("color", ""),
|
||||
"force_color_match": True,
|
||||
}
|
||||
for r in requirements
|
||||
if r.get("type") and r.get("color")
|
||||
]
|
||||
if overrides:
|
||||
filament_overrides_json = json.dumps(overrides)
|
||||
|
||||
queue_item = PrintQueueItem(
|
||||
printer_id=self.target_printer_id,
|
||||
target_model=target_model,
|
||||
@@ -363,6 +400,8 @@ class VirtualPrinterInstance:
|
||||
position=1,
|
||||
status="pending",
|
||||
manual_start=not self.auto_dispatch,
|
||||
required_filament_types=required_filament_types_json,
|
||||
filament_overrides=filament_overrides_json,
|
||||
)
|
||||
db.add(queue_item)
|
||||
await db.commit()
|
||||
@@ -894,6 +933,7 @@ class VirtualPrinterManager:
|
||||
serial_suffix=vp.serial_suffix,
|
||||
target_printer_id=vp.target_printer_id,
|
||||
auto_dispatch=vp.auto_dispatch,
|
||||
queue_force_color_match=vp.queue_force_color_match,
|
||||
bind_ip=vp.bind_ip or "",
|
||||
remote_interface_ip=vp.remote_interface_ip or "",
|
||||
tailscale_disabled=vp.tailscale_disabled,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Unit tests for `extract_filament_requirements` (#1188).
|
||||
|
||||
The helper is the parser the scheduler used to own and the VP queue-mode
|
||||
write path now also uses. Pin the contract end-to-end so a refactor of one
|
||||
caller can't silently break the other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.services.filament_requirements import extract_filament_requirements
|
||||
|
||||
|
||||
def _make_3mf(
|
||||
file_path: Path,
|
||||
*,
|
||||
plates: list[tuple[int, list[dict]]] | None = None,
|
||||
flat_filaments: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""Build a minimal 3MF zip. Either ``plates`` (list of
|
||||
``(plate_index, filaments)``) or ``flat_filaments`` (no plate wrapper)
|
||||
drives the slice_info.config shape."""
|
||||
|
||||
def _filament_xml(filaments: list[dict]) -> str:
|
||||
return "".join(
|
||||
f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
|
||||
f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
|
||||
for f in filaments
|
||||
)
|
||||
|
||||
if plates is not None:
|
||||
plate_xml = "".join(
|
||||
f'<plate><metadata key="index" value="{idx}"/>{_filament_xml(fs)}</plate>' for idx, fs in plates
|
||||
)
|
||||
body = plate_xml
|
||||
elif flat_filaments is not None:
|
||||
body = _filament_xml(flat_filaments)
|
||||
else:
|
||||
body = ""
|
||||
config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
|
||||
with zipfile.ZipFile(file_path, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", config)
|
||||
|
||||
|
||||
class TestExtractFilamentRequirements:
|
||||
def test_returns_per_slot_dicts_for_plate(self, tmp_path: Path):
|
||||
f = tmp_path / "model.3mf"
|
||||
_make_3mf(
|
||||
f,
|
||||
plates=[
|
||||
(
|
||||
1,
|
||||
[
|
||||
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.5"},
|
||||
{"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.2"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
out = extract_filament_requirements(f, plate_id=1)
|
||||
assert out == [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "used_grams": 12.5},
|
||||
{"slot_id": 2, "type": "PETG", "color": "#000000", "tray_info_idx": "", "used_grams": 4.2},
|
||||
]
|
||||
|
||||
def test_skips_zero_use_filaments(self, tmp_path: Path):
|
||||
"""Slot present in slice_info.config but `used_g <= 0` means the
|
||||
plate doesn't actually consume that filament — must not show up."""
|
||||
f = tmp_path / "model.3mf"
|
||||
_make_3mf(
|
||||
f,
|
||||
plates=[
|
||||
(
|
||||
1,
|
||||
[
|
||||
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
|
||||
{"id": "2", "type": "ABS", "color": "#FF0000", "used_g": "0"},
|
||||
{"id": "3", "type": "PETG", "color": "#00FF00", "used_g": "-1"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
out = extract_filament_requirements(f, plate_id=1)
|
||||
assert [r["slot_id"] for r in out] == [1]
|
||||
|
||||
def test_filters_to_requested_plate(self, tmp_path: Path):
|
||||
f = tmp_path / "multi.3mf"
|
||||
_make_3mf(
|
||||
f,
|
||||
plates=[
|
||||
(1, [{"id": "1", "type": "PLA", "color": "#FFF", "used_g": "5"}]),
|
||||
(2, [{"id": "1", "type": "PETG", "color": "#000", "used_g": "5"}]),
|
||||
],
|
||||
)
|
||||
assert extract_filament_requirements(f, plate_id=1)[0]["type"] == "PLA"
|
||||
assert extract_filament_requirements(f, plate_id=2)[0]["type"] == "PETG"
|
||||
|
||||
def test_no_plate_id_walks_flat_filaments(self, tmp_path: Path):
|
||||
"""When the slice_info.config has no plate wrapper (some older
|
||||
Studio versions), we still pick up flat ``./filament`` children."""
|
||||
f = tmp_path / "flat.3mf"
|
||||
_make_3mf(
|
||||
f,
|
||||
flat_filaments=[{"id": "1", "type": "PLA", "color": "#FFF", "used_g": "5"}],
|
||||
)
|
||||
out = extract_filament_requirements(f, plate_id=None)
|
||||
assert len(out) == 1
|
||||
assert out[0]["type"] == "PLA"
|
||||
|
||||
def test_returns_empty_list_for_unparseable_file(self, tmp_path: Path):
|
||||
f = tmp_path / "bad.3mf"
|
||||
f.write_bytes(b"not a zip")
|
||||
assert extract_filament_requirements(f, plate_id=1) == []
|
||||
|
||||
def test_returns_empty_list_for_missing_file(self, tmp_path: Path):
|
||||
assert extract_filament_requirements(tmp_path / "nope.3mf", plate_id=1) == []
|
||||
|
||||
def test_returns_empty_list_when_slice_info_missing(self, tmp_path: Path):
|
||||
"""3MF without `Metadata/slice_info.config` (e.g. a model-only
|
||||
export) must degrade gracefully."""
|
||||
f = tmp_path / "no-config.3mf"
|
||||
with zipfile.ZipFile(f, "w") as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
assert extract_filament_requirements(f, plate_id=1) == []
|
||||
|
||||
def test_results_are_sorted_by_slot_id(self, tmp_path: Path):
|
||||
f = tmp_path / "unordered.3mf"
|
||||
_make_3mf(
|
||||
f,
|
||||
plates=[
|
||||
(
|
||||
1,
|
||||
[
|
||||
{"id": "3", "type": "PLA", "color": "#FFF", "used_g": "1"},
|
||||
{"id": "1", "type": "PLA", "color": "#000", "used_g": "1"},
|
||||
{"id": "2", "type": "PLA", "color": "#F00", "used_g": "1"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
out = extract_filament_requirements(f, plate_id=1)
|
||||
assert [r["slot_id"] for r in out] == [1, 2, 3]
|
||||
@@ -4,12 +4,38 @@ Tests the virtual printer manager, FTP server, and SSDP server components.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_3mf_with_filaments(file_path: Path, filaments: list[dict], plate_index: int = 1) -> None:
|
||||
"""Build a minimal 3MF zip with `Metadata/slice_info.config` carrying the
|
||||
given per-slot filament entries. Each `filaments` dict needs `id`, `type`,
|
||||
`color`, `used_g`. Used by the #1188 VP queue-mode tests below."""
|
||||
filament_xml = "".join(
|
||||
f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
|
||||
f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
|
||||
for f in filaments
|
||||
)
|
||||
config = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
"<config>"
|
||||
f'<plate><metadata key="index" value="{plate_index}"/>'
|
||||
f"{filament_xml}"
|
||||
"</plate>"
|
||||
"</config>"
|
||||
)
|
||||
with zipfile.ZipFile(file_path, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", config)
|
||||
# Plate gcode is referenced for plate-id detection in the VP path —
|
||||
# presence is enough; contents don't matter.
|
||||
zf.writestr(f"Metadata/plate_{plate_index}.gcode", "; gcode\n")
|
||||
|
||||
|
||||
class TestVirtualPrinterInstance:
|
||||
"""Tests for VirtualPrinterInstance class."""
|
||||
|
||||
@@ -291,6 +317,207 @@ class TestVirtualPrinterInstance:
|
||||
queue_item = added_items[0]
|
||||
assert queue_item.manual_start is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
|
||||
"""#1188: VP queue-mode used to create PrintQueueItems with no
|
||||
filament fields, so the scheduler fell through to model-only matching
|
||||
and dispatched onto whatever printer was free regardless of loaded
|
||||
colour. ``required_filament_types`` is populated unconditionally
|
||||
(cheap, helps the scheduler validate type even without
|
||||
``force_color_match``) — pin that contract here."""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(side_effect=added_items.append)
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=21,
|
||||
name="Reqs",
|
||||
mode="print_queue",
|
||||
model="C12",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800021",
|
||||
auto_dispatch=True,
|
||||
queue_force_color_match=False, # off → only required_filament_types
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "multi.3mf"
|
||||
_write_3mf_with_filaments(
|
||||
file_path,
|
||||
[
|
||||
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"},
|
||||
{"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"},
|
||||
# used_g=0 → not actually consumed by this plate, must be ignored
|
||||
{"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"},
|
||||
],
|
||||
plate_index=1,
|
||||
)
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "multi"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
queue_item = added_items[0]
|
||||
# Type-only fallback always populated. Sorted, deduped, no zero-use ABS.
|
||||
assert queue_item.required_filament_types is not None
|
||||
assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"]
|
||||
# Setting off → no force_color_match overrides leaked.
|
||||
assert queue_item.filament_overrides is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path):
|
||||
"""#1188 core fix: when the per-VP ``queue_force_color_match`` toggle
|
||||
is on, every consumed slot lands as a ``filament_overrides`` entry
|
||||
with ``force_color_match: true``. This is the field the scheduler
|
||||
keys on (``print_scheduler.py:512``) — without it, slot-by-slot
|
||||
type+color matching never runs."""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(side_effect=added_items.append)
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=22,
|
||||
name="ForceColor",
|
||||
mode="print_queue",
|
||||
model="C12",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800022",
|
||||
auto_dispatch=True,
|
||||
queue_force_color_match=True, # on
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "forced.3mf"
|
||||
_write_3mf_with_filaments(
|
||||
file_path,
|
||||
[
|
||||
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
|
||||
{"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"},
|
||||
],
|
||||
plate_index=1,
|
||||
)
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "forced"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
queue_item = added_items[0]
|
||||
assert queue_item.filament_overrides is not None
|
||||
overrides = json.loads(queue_item.filament_overrides)
|
||||
assert overrides == [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
|
||||
{"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
|
||||
]
|
||||
# required_filament_types still populated alongside overrides.
|
||||
assert json.loads(queue_item.required_filament_types) == ["PLA"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
|
||||
"""A malformed or fake-bytes 3MF must not crash the upload path —
|
||||
we just write the queue item with no filament fields and let the
|
||||
scheduler fall back to model-only matching (the pre-#1188 default).
|
||||
Regression guard for the existing fake-bytes happy-path tests."""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(side_effect=added_items.append)
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=23,
|
||||
name="Unparseable",
|
||||
mode="print_queue",
|
||||
model="C12",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800023",
|
||||
auto_dispatch=True,
|
||||
queue_force_color_match=True,
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "bad.3mf"
|
||||
file_path.write_bytes(b"not a real 3mf zip")
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "bad"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
queue_item = added_items[0]
|
||||
# No filament data extractable → both fields stay None (graceful
|
||||
# fallback to model-only scheduling).
|
||||
assert queue_item.required_filament_types is None
|
||||
assert queue_item.filament_overrides is None
|
||||
|
||||
# ========================================================================
|
||||
# Tests for archive_name_source setting (#1152)
|
||||
# ========================================================================
|
||||
|
||||
@@ -45,6 +45,7 @@ const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): Virtu
|
||||
serial: '00M00A391800001',
|
||||
target_printer_id: null,
|
||||
auto_dispatch: true,
|
||||
queue_force_color_match: false,
|
||||
bind_ip: null,
|
||||
remote_interface_ip: null,
|
||||
position: 0,
|
||||
@@ -135,6 +136,101 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #1188 — VP queue mode now pins per-slot type+color so the scheduler refuses
|
||||
// to dispatch onto a printer with the wrong filament loaded. The toggle is
|
||||
// mode-gated to print_queue (mirroring the auto-dispatch toggle), defaults
|
||||
// off (preserves pre-fix behaviour for upgraders), and the click both flips
|
||||
// the local state and POSTs the new value to the backend.
|
||||
describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
|
||||
});
|
||||
|
||||
it('renders force-color-match toggle when mode is print_queue', async () => {
|
||||
const printer = createMockPrinter({ mode: 'print_queue' });
|
||||
render(<VirtualPrinterCard printer={printer} models={models} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Force color match')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render force-color-match toggle when mode is immediate', async () => {
|
||||
const printer = createMockPrinter({ mode: 'immediate' });
|
||||
render(<VirtualPrinterCard printer={printer} models={models} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test VP')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Force color match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render force-color-match toggle when mode is proxy', async () => {
|
||||
const printer = createMockPrinter({ mode: 'proxy' });
|
||||
render(<VirtualPrinterCard printer={printer} models={models} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test VP')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Force color match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('force-color-match toggle defaults off (not green) — preserves pre-fix behaviour', async () => {
|
||||
const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: false });
|
||||
render(<VirtualPrinterCard printer={printer} models={models} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Force color match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const title = screen.getByText('Force color match');
|
||||
const section = title.closest('.flex.items-center.justify-between');
|
||||
expect(section).toBeTruthy();
|
||||
const toggleButton = section!.querySelector('button');
|
||||
expect(toggleButton).toBeTruthy();
|
||||
expect(toggleButton!.className).not.toContain('bg-bambu-green');
|
||||
});
|
||||
|
||||
it('force-color-match toggle renders enabled (green) when queue_force_color_match is true', async () => {
|
||||
const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: true });
|
||||
render(<VirtualPrinterCard printer={printer} models={models} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Force color match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const title = screen.getByText('Force color match');
|
||||
const section = title.closest('.flex.items-center.justify-between');
|
||||
const toggleButton = section!.querySelector('button');
|
||||
expect(toggleButton!.className).toContain('bg-bambu-green');
|
||||
});
|
||||
|
||||
it('clicking force-color-match toggle posts queue_force_color_match in update body', async () => {
|
||||
const user = userEvent.setup();
|
||||
const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: false });
|
||||
vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
|
||||
createMockPrinter({ mode: 'print_queue', queue_force_color_match: true })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterCard printer={printer} models={models} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Force color match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const title = screen.getByText('Force color match');
|
||||
const section = title.closest('.flex.items-center.justify-between');
|
||||
const toggleButton = section!.querySelector('button');
|
||||
|
||||
await user.click(toggleButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { queue_force_color_match: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VirtualPrinterCard - tailscale toggle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -5710,6 +5710,7 @@ export interface VirtualPrinterConfig {
|
||||
serial: string;
|
||||
target_printer_id: number | null;
|
||||
auto_dispatch: boolean;
|
||||
queue_force_color_match: boolean;
|
||||
tailscale_disabled: boolean;
|
||||
bind_ip: string | null;
|
||||
remote_interface_ip: string | null;
|
||||
@@ -5735,6 +5736,7 @@ export const multiVirtualPrinterApi = {
|
||||
access_code?: string;
|
||||
target_printer_id?: number;
|
||||
auto_dispatch?: boolean;
|
||||
queue_force_color_match?: boolean;
|
||||
bind_ip?: string;
|
||||
remote_interface_ip?: string;
|
||||
}) =>
|
||||
@@ -5751,6 +5753,7 @@ export const multiVirtualPrinterApi = {
|
||||
access_code?: string;
|
||||
target_printer_id?: number;
|
||||
auto_dispatch?: boolean;
|
||||
queue_force_color_match?: boolean;
|
||||
tailscale_disabled?: boolean;
|
||||
bind_ip?: string;
|
||||
remote_interface_ip?: string;
|
||||
|
||||
@@ -43,6 +43,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
|
||||
const [localModel, setLocalModel] = useState(printer.model || '');
|
||||
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
|
||||
const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
|
||||
const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
@@ -99,6 +100,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
|
||||
setLocalModel(printer.model || '');
|
||||
setLocalAutoDispatch(printer.auto_dispatch ?? true);
|
||||
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
|
||||
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
|
||||
}
|
||||
}, [printer, pendingAction]);
|
||||
@@ -380,6 +382,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Force-color-match toggle - only for print_queue mode (#1188) */}
|
||||
{localMode === 'print_queue' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm font-medium">{t('virtualPrinter.queueForceColorMatch.title')}</div>
|
||||
<div className="text-[10px] text-bambu-gray">{t('virtualPrinter.queueForceColorMatch.description')}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newVal = !localQueueForceColorMatch;
|
||||
setLocalQueueForceColorMatch(newVal);
|
||||
setPendingAction('queueForceColorMatch');
|
||||
updateMutation.mutate({ queue_force_color_match: newVal });
|
||||
}}
|
||||
disabled={pendingAction === 'queueForceColorMatch'}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
|
||||
localQueueForceColorMatch ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
} ${pendingAction === 'queueForceColorMatch' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
localQueueForceColorMatch ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tailscale toggle */}
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -4054,6 +4054,10 @@ export default {
|
||||
title: 'Automatisch starten',
|
||||
description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Farbabgleich erzwingen',
|
||||
description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale-Integration',
|
||||
description: 'Wenn aktiviert, werden Tailscale-zertifizierte TLS-Zertifikate verwendet. Deaktivieren für selbstsignierte Zertifikate.',
|
||||
|
||||
@@ -4063,6 +4063,10 @@ export default {
|
||||
title: 'Auto-dispatch',
|
||||
description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale integration',
|
||||
description: 'When enabled, uses Tailscale for trusted TLS certs. Disable to use self-signed cert only.',
|
||||
|
||||
@@ -4042,6 +4042,10 @@ export default {
|
||||
title: 'Lancement automatique',
|
||||
description: 'Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Intégration Tailscale',
|
||||
description: 'Lorsqu\'activé, utilise Tailscale pour des certificats TLS de confiance. Désactiver pour n\'utiliser que des certificats auto-signés.',
|
||||
|
||||
@@ -4041,6 +4041,10 @@ export default {
|
||||
title: 'Avvio automatico',
|
||||
description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\'avvio manuale.',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Integrazione Tailscale',
|
||||
description: 'Quando abilitato, utilizza Tailscale per certificati TLS affidabili. Disabilita per utilizzare solo certificati auto-firmati.',
|
||||
|
||||
@@ -4054,6 +4054,10 @@ export default {
|
||||
title: '自動ディスパッチ',
|
||||
description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale統合',
|
||||
description: '有効にすると、Tailscaleを使用して信頼できるTLS証明書を使用します。自己署名証明書のみを使用する場合は無効にします。',
|
||||
|
||||
@@ -4041,6 +4041,10 @@ export default {
|
||||
title: 'Envio automático',
|
||||
description: 'Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Integração Tailscale',
|
||||
description: 'Quando ativado, usa Tailscale para certificados TLS confiáveis. Desative para usar apenas certificado autoassinado.',
|
||||
|
||||
@@ -4042,6 +4042,10 @@ export default {
|
||||
title: '自动派发',
|
||||
description: '添加到队列时自动开始打印。关闭后,打印任务等待手动派发。',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale 集成',
|
||||
description: '启用后,使用 Tailscale 获取受信任的 TLS 证书。禁用则仅使用自签名证书。',
|
||||
|
||||
@@ -4042,6 +4042,10 @@ export default {
|
||||
title: '自動派發',
|
||||
description: '新增到佇列時自動開始列印。關閉後,列印任務等待手動派發。',
|
||||
},
|
||||
queueForceColorMatch: {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale 整合',
|
||||
description: '啟用後,使用 Tailscale 取得受信任的 TLS 憑證。停用則僅使用自簽憑證。',
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BijNyfT8.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BeWa0UNk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cw7zekS6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user