● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)

Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
  queue, started from the queue, and got no timelapse video. Their
  dispatch chain itself was correct (queue item -> scheduler -> MQTT
  command honors `timelapse`); the gap was at queue-add time.

  The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
  other print-option settings) from the workflow settings card. That was
  introduced in #1235 to stop column-level defaults from winning. But it
  also discarded the slicer's actual choice carried on the MQTT
  `project_file` command, which all the slicers (Studio / Handy / Orca)
  ship as `timelapse: true|1`. Result: a user with the new-install value
  `default_timelapse=false` had to either flip the global setting or
  edit every queue item by hand, even though their slicer's "Print
  options" UI clearly said "record timelapse".

  Investigation went wider than #1403 because Martin's hypothesis was
  "the print options modal isn't respected either." Cross-checking
  86 captured P1S `project_file` commands across the support packages
  shows 46 from the queue scheduler and 33 from background_dispatch
  emitting `"timelapse": true` correctly to real printers - the modal +
  re-print path is intact end-to-end. The slicer-side gap was the only
  real bug. Two unrelated dead-code issues turned up in the same dig and
  are folded in below.

  Fix (VP queue inheritance)

  - `on_print_command` in the VP manager now stashes the slicer's
    project_file dict keyed by filename, then signals an asyncio.Event.
  - `_add_to_print_queue` checks the dict first; if empty, creates the
    event and waits up to 2 s for it before reading the settings
    fallback. Each option flows through per-field - slicer value wins
    if present, else the existing settings default (so users who
    explicitly set `default_timelapse=true` in their VP workflow card
    still get that on slicers that don't send a print command).
  - MQTT field naming preserved exactly: `bed_leveling` (single L) on
    the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
    column. Integer 0/1 from H-family slicers and bool true/false from
    P1/X1 slicers both coerce via `bool()`.
  - Capture is gated on `mode == "print_queue"` so immediate / review /
    proxy modes keep their pre-fix no-op `on_print_command` and don't
    accumulate stashed entries over the VP's uptime.
  - Wait is also skipped when there's no MQTT server attached
    (`self._mqtt is None`), so unit tests that invoke
    `_add_to_print_queue` directly don't pay the 2 s tax.
  - Capture is consumed on use so the dict stays bounded.
  - `printer_manager.get_status(...).get(...)` against a `PrinterState`
    dataclass that has no `.get()` method.
  - Every print option discarded (timelapse, bed_levelling, AMS mapping).

  The route 500'd before ever reaching the printer. Rewritten to mirror
  `POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
  next pending queue item and let the scheduler dispatch with the
  queue's stored options intact. Response shape preserved.

  Side-bug b: vibration_cali default drift in background_dispatch

  - `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
    both default to `True` (matches Bambu Studio behavior for X1/P1).
  - Both `_process_job` call sites read
    `job.options.get("vibration_cali", False)`.

  Cosmetic today because the frontend always sends the field, but a
  latent landmine for any future caller that bypasses the schema. Both
  sites flipped to `True`.
This commit is contained in:
maziggy
2026-05-18 09:38:53 +02:00
parent e61a454a0f
commit 173edd9b7c
7 changed files with 422 additions and 27 deletions
+2
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Fixed
- **Virtual Printer queue: timelapse / bed-leveling / flow-cali / vibration-cali / layer-inspect now inherit the slicer's choice instead of always falling back to global defaults (#1403, reported by @pwostran)** — Reporter sliced in OrcaSlicer with timelapse enabled, sent to a VP queue, started the job from the queue and got no timelapse video. The dispatch chain itself was correct (queue item → scheduler → MQTT command honours `timelapse`); the gap was at queue-add time: the VP's `_add_to_print_queue` read `default_timelapse` from settings (introduced in #1235 to stop column defaults from winning), but ignored the slicer's project_file MQTT command entirely. The slicer's choice — which Bambu Handy / Bambu Studio / Orca all surface in their "Print options" dialog and ship in the MQTT payload as `timelapse: true|1` — was being thrown away. So a user with `default_timelapse=false` (the new-install value) would have to either flip the global setting or manually edit every queue item, even though their slicer's UI was clearly saying "record timelapse for this job". Fix: `on_print_command` in the VP manager now stashes the slicer's project_file dict keyed by filename, and `_add_to_print_queue` waits up to 2 s for that capture before reading the settings fallback. Each option flows through per-field — slicer value wins if present, else the existing settings default (so users who explicitly set `default_timelapse=true` in their VP workflow card still get that on slicers that don't send a print command). MQTT field naming is preserved exactly: `bed_leveling` (single L) on the wire stays mapped to `bed_levelling` (double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce correctly via `bool()`. Wait is skipped when there's no MQTT server attached to the VP instance (covers unit tests calling `_add_to_print_queue` directly so they don't pay the 2 s tax) and capture is consumed on use so the dict stays bounded across many prints. Two new regressions in `test_virtual_printer.py::TestPrintQueueMode`: `test_add_to_print_queue_inherits_slicer_print_options` (slicer=True overrides settings=False across all 5 fields; capture is consumed) and `test_add_to_print_queue_coerces_slicer_integer_zero_one` (H-family integer payload is coerced). The existing `#1235` test (`test_add_to_print_queue_uses_workflow_defaults_from_settings`) still passes because the no-MQTT-attached gate skips the wait, so the settings fallback path is preserved when no slicer capture exists. **Plus two side-bugs surfaced while investigating Martin's "modal not respected" hypothesis** — the support-package evidence cleared the reprint modal (46 `print_scheduler` and 33 `background_dispatch` events with `timelapse: true` shipped to real P1S printers, end-to-end working), but the same dig turned up two latent issues worth fixing in the same pass: (a) **`POST /webhook/printer/{id}/start` was broken on four axes** — `await printer_manager.start_print(...)` against a `def` (not `async def`) function, `queue_item.archive_id` (int) passed as the `filename` arg, `printer_manager.get_status(...).get(...)` against a `PrinterState` dataclass (not a dict), and every print option discarded (timelapse, bed_levelling, AMS mapping). The route would 500 before ever reaching the printer. Rewritten to mirror `POST /print-queue/{item_id}/start`: just clear `manual_start=False` on the next pending queue item and let the scheduler dispatch it with the queue's stored options intact. Three new regressions in `test_webhook_start_print.py` (clears `manual_start`, preserves stored print options, 404 when no pending items / unknown printer). (b) **`vibration_cali` default drift in `background_dispatch.py`** — `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali` both default to `True` (matches Bambu Studio behaviour for X1/P1 series), but the two `_process_job` call sites read `job.options.get("vibration_cali", False)`. Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema (e.g. an internal dispatcher seeding options programmatically). Both call sites flipped to `True`; new contract test `test_dispatch_option_defaults_align_with_request_schema_defaults` introspects the source to lock the alignment for all six print-option fields so this drift can't recur. 116 VP unit tests green; 4999 backend tests green; ruff clean.
- **Inventory: "Reset usage to 0" no longer inflates remaining weight back to label_weight (#1390 follow-up, reported by @IndividualGhost1905)** — Reporter reset a 544 g spool's consumed counter and watched its displayed remaining jump to 1000 g — exactly the opposite of what the dialog promised ("Spools and remaining weights are not changed"). Root cause was an architectural conflation in the internal inventory model: a single `weight_used` column was doing two jobs, the resettable "consumed since tracking started" stat AND the basis for the displayed remaining (`label_weight - weight_used`). Zeroing it correctly cleared the stat but unavoidably reset remaining to full. Spoolman has separate `used_weight` and `remaining_weight` fields, so its API call was correct, but Bambuddy's frontend was *also* computing remaining as `label_weight - weight_used` for Spoolman spools (ignoring Spoolman's real `remaining_weight` field), so the same visual bug bit in Spoolman mode too. **Internal mode fix:** new `weight_used_baseline` column (Float, default 0) on the `spool` table; the "Total Consumed" display is now `weight_used - weight_used_baseline` clamped to ≥0; the reset endpoints stamp `baseline = weight_used` and leave `weight_used` untouched, so remaining (= `label_weight - weight_used`) is preserved. Subsequent prints continue to grow `weight_used` and the resettable counter naturally tracks the post-reset delta. **Spoolman parity fix:** `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field and returns a synthetic `weight_used = label_weight - remaining_weight` so the frontend's remaining calc matches Spoolman's real stored value; `weight_used_baseline` is computed as `synthetic_weight_used - real_used_weight` so `weight_used - baseline` equals Spoolman's `used_weight` (the resettable counter). After a Spoolman reset (real used_weight=0, real remaining_weight=544) the user sees consumed=0 and remaining=544 — identical to internal mode. Also fixed a related Spoolman bug: editing a spool's metadata after a reset would PATCH Spoolman with `remaining_weight = label - used_weight = 1000`, overwriting the real 544 g; `update_spool` now derives the default `weight_used` from Spoolman's `remaining_weight` instead of `used_weight` so non-weight edits preserve the existing physical state. Frontend `totalConsumed` aggregate in `InventoryPage` and the three "consumed" displays in `ForecastPanel` (delta-rate, per-SKU totalUsedG, per-spool consumed cell) all switched to `Math.max(0, weight_used - (weight_used_baseline ?? 0))`. The `?? 0` fallback keeps pre-migration installs rendering correctly until `init_db()` runs the idempotent `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0` (works on both SQLite and Postgres). **Tests:** `test_spool_reset_usage.py` rewritten — old asserts that the endpoint zeroed `weight_used` now assert it stamps baseline = weight_used and leaves weight_used alone, plus a new `test_reset_then_print_advances_only_the_counter` test that simulates a 50 g print after a reset and confirms `consumed=50, remaining=494` (i.e. remaining keeps decrementing across the reset). `test_spoolman_inventory_helpers.py` gets two new tests on the mapper covering pre-reset and post-reset Spoolman shapes. `test_spoolman_inventory_api.py::test_reset_spool_usage` updated to assert the new InventorySpool contract (consumed=0, remaining=750, baseline absorbs the reset). 4993 backend tests green; ruff clean; frontend build clean. Per the inventory-parity rule saved last session: both modes now ship the same UX, both call sites verified end-to-end before declaring done.
- **Adding a printer with a wrong access code (or unreachable IP) no longer creates an empty card** — Several support reports traced back to a single root cause: the user mistyped their access code in the Add Printer dialog, `POST /printers/` happily persisted the row, the subsequent `printer_manager.connect_printer()` call was fire-and-forget so the failure was invisible, and the dashboard ended up showing a printer card that could never display state. The create route now runs `printer_manager.test_connection()` (the same MQTT probe the standalone Test Connection button has always used) BEFORE inserting the row, and refuses with HTTP 400 if the probe fails. The Printer row is never written on failure. **Structured error response**: backend returns `{detail: {code: "printer_connection_failed", message: "..."}}` rather than a plain English string — the new `ApiError.code` field on the frontend lets the toast layer pick a localized `printers.toast.connectionFailedNotAdded` key instead of surfacing the English fallback. **Existing tests** kept green via an autouse `_mock_printer_test_connection` fixture in `test_printers_api.py` that defaults the probe to success; a new `test_create_printer_rejects_when_mqtt_probe_fails` asserts the failure path returns 400, surfaces the stable code, AND verifies the row was not persisted (the critical part — earlier versions of the regression would have passed even if we'd left the row behind). 8 new i18n translations for `printers.toast.connectionFailedNotAdded` across all 8 locales; parity holds at 4831 leaves. 28 printer-route tests green.
+17 -19
View File
@@ -136,7 +136,16 @@ async def webhook_start_print(
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Start the next queued print on a printer.
"""Trigger the next manual-start queue item on a printer.
Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
the next pending item so the scheduler picks it up — which handles
FTP upload, AMS mapping, and all print options (timelapse,
bed_levelling, etc.) correctly via the queue's stored fields. The
previous implementation called `printer_manager.start_print()`
directly with `archive_id` as the filename arg and no print options,
bypassing the upload step entirely and discarding the user's
workflow choices — it 500'd before ever reaching the printer.
Requires 'can_control_printer' permission.
"""
@@ -163,25 +172,14 @@ async def webhook_start_print(
if not queue_item:
raise HTTPException(status_code=404, detail="No pending prints in queue")
# Check if printer is ready
status = printer_manager.get_status(printer_id)
if not status or not status.get("connected"):
raise HTTPException(status_code=503, detail="Printer not connected")
if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
raise HTTPException(status_code=409, detail=f"Printer is busy (state: {status.get('state')})")
# Start the print with plate_id if available
try:
await printer_manager.start_print(
printer_id,
queue_item.archive_id,
plate_id=queue_item.plate_id or 1,
)
except Exception as e:
logger.error("Failed to start print: %s", e)
raise HTTPException(status_code=500, detail=str(e))
# Clear manual_start so the scheduler will dispatch. If the item was
# already auto-dispatchable this is a no-op; the scheduler will still
# pick it up on its next tick.
queue_item.manual_start = False
await db.commit()
await db.refresh(queue_item)
logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
return {"message": "Print started", "queue_item_id": queue_item.id}
+2 -2
View File
@@ -681,7 +681,7 @@ class BackgroundDispatchService:
timelapse=job.options.get("timelapse", False),
bed_levelling=job.options.get("bed_levelling", True),
flow_cali=job.options.get("flow_cali", False),
vibration_cali=job.options.get("vibration_cali", False),
vibration_cali=job.options.get("vibration_cali", True),
layer_inspect=job.options.get("layer_inspect", False),
use_ams=job.options.get("use_ams", True),
)
@@ -886,7 +886,7 @@ class BackgroundDispatchService:
timelapse=job.options.get("timelapse", False),
bed_levelling=job.options.get("bed_levelling", True),
flow_cali=job.options.get("flow_cali", False),
vibration_cali=job.options.get("vibration_cali", False),
vibration_cali=job.options.get("vibration_cali", True),
layer_inspect=job.options.get("layer_inspect", False),
use_ams=job.options.get("use_ams", True),
)
@@ -160,6 +160,18 @@ class VirtualPrinterInstance:
# Pending files for MQTT correlation
self._pending_files: dict[str, Path] = {}
# Slicer-side print options captured from the MQTT `project_file`
# command, keyed by filename. Used by `_add_to_print_queue` so the
# queue item inherits the user's slicer-chosen timelapse / bed_leveling
# / flow_cali / vibration_cali / layer_inspect / use_ams toggles rather
# than falling back to the global `default_*` settings (#1403). FTP
# completes a few hundred ms before the slicer's MQTT `project_file`
# arrives, so the queue-add path waits briefly on the event below
# before reading the dict. Events are popped along with the options
# so the dict stays bounded.
self._slicer_print_options: dict[str, dict] = {}
self._slicer_print_options_events: dict[str, asyncio.Event] = {}
# Per-instance services
self._proxy: SlicerProxyManager | None = None
self._ftp: VirtualPrinterFTPServer | None = None
@@ -231,8 +243,24 @@ class VirtualPrinterInstance:
self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100")
async def on_print_command(self, filename: str, data: dict) -> None:
"""Handle print command from MQTT."""
"""Handle print command from MQTT.
Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
`flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
VP-queue path can inherit them when adding the item to the queue,
rather than falling back to the global default settings (#1403).
Only queue mode consumes the capture; immediate / review / proxy
modes ignore the print command, so we skip the stash there to keep
the dict from accumulating one entry per print over the VP's
uptime.
"""
logger.info("[VP %s] Print command for: %s", self.name, filename)
if self.mode != "print_queue":
return
self._slicer_print_options[filename] = dict(data)
event = self._slicer_print_options_events.get(filename)
if event:
event.set()
async def _archive_file(self, file_path: Path, source_ip: str) -> None:
"""Archive file immediately."""
@@ -344,6 +372,29 @@ class VirtualPrinterInstance:
pass
return
# Wait briefly for the slicer's MQTT `project_file` command so the
# queue item can inherit the slicer-side print options the user
# picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
# first and the MQTT command immediately after, so the typical lag
# is a few hundred ms; 2 s is conservative without making every
# VP-queue add visibly slow. Falls back to the global default_*
# settings if MQTT doesn't arrive in time (legacy behaviour for
# users on a slicer that doesn't send a print command). #1403.
# The wait is skipped when there's no MQTT server attached — covers
# unit tests that invoke `_add_to_print_queue` directly without
# going through `on_print_command`, so they don't pay the 2 s tax.
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
if slicer_opts is None and self._mqtt is not None:
event = asyncio.Event()
self._slicer_print_options_events[file_path.name] = event
try:
await asyncio.wait_for(event.wait(), timeout=2.0)
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
except asyncio.TimeoutError:
slicer_opts = None
finally:
self._slicer_print_options_events.pop(file_path.name, None)
try:
import json
@@ -360,14 +411,36 @@ class VirtualPrinterInstance:
# PrintQueueItem below would fall back to the column-level
# defaults and ignore the user's workflow preferences (#1235).
# Fallbacks match AppSettings defaults in schemas/settings.py.
# The slicer-side options captured above (if any) take
# precedence per-field over these defaults.
def _bool_setting(value: str | None, default: bool) -> bool:
return value.lower() == "true" if value is not None else default
bed_levelling = _bool_setting(await get_setting(db, "default_bed_levelling"), True)
flow_cali = _bool_setting(await get_setting(db, "default_flow_cali"), False)
vibration_cali = _bool_setting(await get_setting(db, "default_vibration_cali"), True)
layer_inspect = _bool_setting(await get_setting(db, "default_layer_inspect"), False)
timelapse = _bool_setting(await get_setting(db, "default_timelapse"), False)
def _slicer_or(field_mqtt: str, settings_default: bool) -> bool:
"""Slicer's MQTT value if present, else the settings default.
Slicer payloads carry both bool and int (0/1) shapes
depending on firmware family — coerce via bool() so
`0`/`False` and `1`/`True` both work.
"""
if slicer_opts is not None and field_mqtt in slicer_opts:
return bool(slicer_opts[field_mqtt])
return settings_default
# Note the MQTT field names differ from Bambuddy's column
# names: MQTT uses `bed_leveling` (single L) while the
# column / settings key use `bed_levelling` (double L).
bed_levelling = _slicer_or(
"bed_leveling", _bool_setting(await get_setting(db, "default_bed_levelling"), True)
)
flow_cali = _slicer_or("flow_cali", _bool_setting(await get_setting(db, "default_flow_cali"), False))
vibration_cali = _slicer_or(
"vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
)
layer_inspect = _slicer_or(
"layer_inspect", _bool_setting(await get_setting(db, "default_layer_inspect"), False)
)
timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
service = ArchiveService(db)
archive = await service.archive_print(
@@ -0,0 +1,130 @@
"""Regression tests for the webhook `/printer/{id}/start` route.
The previous implementation called `printer_manager.start_print()` directly
with `queue_item.archive_id` (an int) as the filename arg and no print
options, and used `await` on a non-async function. That route 500'd on
every invocation. The fix mirrors `POST /print-queue/{item_id}/start`:
clear the next pending item's `manual_start` so the scheduler picks it up
with the queue's stored options (timelapse, bed_levelling, etc.) intact.
"""
import pytest
from httpx import AsyncClient
@pytest.fixture
async def api_key_data(async_client: AsyncClient, db_session):
"""Create an API key with control_printer permission."""
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
full_key, key_hash, key_prefix = generate_api_key()
api_key = APIKey(
name="webhook-test-key",
key_hash=key_hash,
key_prefix=key_prefix,
can_queue=True,
can_control_printer=True,
can_read_status=True,
enabled=True,
)
db_session.add(api_key)
await db_session.commit()
return full_key
@pytest.fixture
async def printer_with_queue(db_session):
"""Create a printer and a pending queue item with manual_start=True."""
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
printer = Printer(
name="WebhookTest",
ip_address="192.168.1.42",
access_code="12345678",
serial_number="00M00A000000000",
model="P1S",
)
db_session.add(printer)
await db_session.commit()
item = PrintQueueItem(
printer_id=printer.id,
position=1,
status="pending",
manual_start=True,
timelapse=True,
bed_levelling=True,
flow_cali=False,
vibration_cali=True,
layer_inspect=False,
use_ams=True,
)
db_session.add(item)
await db_session.commit()
return printer, item
class TestWebhookStartPrint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clears_manual_start_on_next_pending_item(
self, async_client: AsyncClient, db_session, api_key_data, printer_with_queue
):
"""The webhook flips manual_start to False so the scheduler picks it up.
Pre-fix the route called `printer_manager.start_print()` directly
with no options and `archive_id` (int) as the filename — 500'd on
every invocation. Now it mirrors the regular `/print-queue/{id}/start`
affordance: scheduler dispatch handles FTP upload and all print
options via the queue's stored fields.
"""
printer, item = printer_with_queue
resp = await async_client.post(
f"/api/v1/webhook/printer/{printer.id}/start",
headers={"X-API-Key": api_key_data},
)
assert resp.status_code == 200, resp.text
assert resp.json()["queue_item_id"] == item.id
await db_session.refresh(item)
assert item.manual_start is False, "manual_start must be cleared so scheduler dispatches"
# Stored options must be untouched so the scheduler picks the user's choice.
assert item.timelapse is True
assert item.bed_levelling is True
assert item.vibration_cali is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_404_when_no_pending_items(self, async_client: AsyncClient, db_session, api_key_data):
from backend.app.models.printer import Printer
printer = Printer(
name="EmptyQueue",
ip_address="192.168.1.43",
access_code="12345678",
serial_number="00M00A000000001",
model="P1S",
)
db_session.add(printer)
await db_session.commit()
resp = await async_client.post(
f"/api/v1/webhook/printer/{printer.id}/start",
headers={"X-API-Key": api_key_data},
)
assert resp.status_code == 404
assert "No pending prints" in resp.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_404_when_printer_does_not_exist(self, async_client: AsyncClient, api_key_data):
resp = await async_client.post(
"/api/v1/webhook/printer/99999/start",
headers={"X-API-Key": api_key_data},
)
assert resp.status_code == 404
@@ -212,6 +212,45 @@ def test_is_sliced_file_recognizes_supported_extensions():
assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
def test_dispatch_option_defaults_align_with_request_schema_defaults():
"""The `job.options.get("<field>", <default>)` calls in the dispatch
loop must use the same default as the Pydantic request schema. If a
field is missing from options (e.g. an internal caller bypassing the
schema), the resulting print command must match what a fresh
`ReprintRequest()` / `FilePrintRequest()` would produce — anything
else means certain fields silently flip depending on which entry
point queued the job.
Earlier `vibration_cali` had a False default in the dispatch loop
against a True schema default, latent only because every existing
caller always sent the field.
"""
import inspect
from backend.app.schemas.archive import ReprintRequest
from backend.app.schemas.library import FilePrintRequest
from backend.app.services import background_dispatch as bd
fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "timelapse", "use_ams")
reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
assert reprint_defaults == libprint_defaults, (
"ReprintRequest and FilePrintRequest must share the same defaults for these fields"
)
src = inspect.getsource(bd)
for field, expected_default in reprint_defaults.items():
literal = "True" if expected_default else "False"
needle = f'{field}=job.options.get("{field}", {literal})'
count = src.count(needle)
assert count == 2, (
f"Expected exactly 2 occurrences of `{needle}` in background_dispatch (one per "
f"`_process_job` branch). Found {count}. A drift between the request schema's "
f"default for `{field}` and the dispatch loop's `.get()` default means callers "
f"that bypass the schema will get inconsistent behaviour."
)
@pytest.mark.asyncio
async def test_cancel_job_not_found_returns_false():
"""Cancelling a nonexistent job returns not_found."""
@@ -611,6 +611,159 @@ class TestVirtualPrinterInstance:
assert queue_item.layer_inspect is False
assert queue_item.timelapse is False
@pytest.mark.asyncio
async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path):
"""#1403: VP-queue items used to fall back to `default_timelapse` even
though the slicer's MQTT `project_file` command carries the user's
actual choice. Capture-via-`on_print_command` flow lets the user's
slicer toggle reach the queue item.
Settings here have timelapse OFF; the slicer's MQTT capture has it ON.
After the fix the queue item must reflect the slicer's choice.
"""
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=24,
name="SlicerInherits",
mode="print_queue",
model="C12",
access_code="12345678",
serial_suffix="391800024",
auto_dispatch=True,
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
# Pre-populate the capture as if MQTT `project_file` arrived already.
# Settings (below) deliberately have timelapse OFF — only the slicer
# capture should drive the resulting queue item.
await inst.on_print_command(
file_path.name,
{
"command": "project_file",
"timelapse": True,
"bed_leveling": False, # Note: MQTT field is single-L `bed_leveling`
"flow_cali": True,
"vibration_cali": False,
"layer_inspect": True,
},
)
settings_map = {
"virtual_printer_archive_name_source": None,
"default_bed_levelling": "true",
"default_flow_cali": "false",
"default_vibration_cali": "true",
"default_layer_inspect": "false",
"default_timelapse": "false",
}
async def fake_get_setting(_db, key):
return settings_map.get(key)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new=fake_get_setting,
),
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.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
assert queue_item.bed_levelling is False, "Slicer's bed_leveling=False must override default_bed_levelling=True"
assert queue_item.flow_cali is True
assert queue_item.vibration_cali is False
assert queue_item.layer_inspect is True
# Capture is consumed — no lingering state for the next print of the same name.
assert file_path.name not in inst._slicer_print_options
@pytest.mark.asyncio
async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path):
"""#1403: H-family firmwares carry calibration flags as integers
(0/1) rather than booleans. The capture must coerce both shapes so
H-family-sliced jobs through the VP queue work the same as P1/X1.
"""
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=25,
name="SlicerIntegers",
mode="print_queue",
model="C12",
access_code="12345678",
serial_suffix="391800025",
auto_dispatch=True,
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
await inst.on_print_command(
file_path.name,
{"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
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.timelapse is True, "integer 1 must coerce to True"
assert queue_item.bed_levelling is False, "integer 0 must coerce to False"
assert queue_item.flow_cali 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