Browse Source

● 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`.
maziggy 1 week ago
parent
commit
173edd9b7c

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 17 - 19
backend/app/api/routes/webhook.py

@@ -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
backend/app/services/background_dispatch.py

@@ -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),
                 )

+ 79 - 6
backend/app/services/virtual_printer/manager.py

@@ -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(

+ 130 - 0
backend/tests/integration/test_webhook_start_print.py

@@ -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

+ 39 - 0
backend/tests/unit/services/test_background_dispatch.py

@@ -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."""

+ 153 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -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

Some files were not shown because too many files changed in this diff