Преглед изворни кода

fix(profiles): read the companion files that hold a preset's real gcode

A bundled preset can keep a setting in `<preset> template <key>.json`, a
file the preset itself does not reference -- the desktop slicer finds it
by name. Walking only `inherits` never reached it, so every one of the 56
instantiable BBL machine presets resolved `machine_start_gcode` to the
577-character generic block on fdm_machine_common instead of its own
6.5-21 KB one. That block holds the M620 AMS load and the M1002
gcode_claim_action calls, so a print sliced from it heats the bed, moves
the toolhead and extrudes nothing (bambuddy#2838).

Companions are now folded into each ancestor as the chain is walked, at
that ancestor's precedence, so a caller's own value still wins and the
0.2/0.6/0.8 variants reach their 0.4 sibling's companion. They are found
by listing rather than by a fixed set of keys.

Covered against the shipped bundle, not fixtures: a new e2e spec resolves
all 56 presets inside the image and fails on any that still lands on the
generic block.
maziggy пре 3 недеља
родитељ
комит
f3b6a503bd

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 21 - 0
backend/app/api/routes/library.py

@@ -72,6 +72,7 @@ from backend.app.services.design_settings import (
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.process_overrides import apply_process_overrides
+from backend.app.services.slice_output_check import missing_start_gcode_message, start_gcode_is_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import (
     MAX_FILENAME_BYTES,
@@ -4210,6 +4211,26 @@ async def _run_slicer_with_fallback(
     finally:
         await service.close()
 
+    # Backstop for #2838. Only the standard tier, and only when the presets we
+    # sent were actually used: there the sidecar resolved a bundled preset by
+    # name and the bundle guarantees the start G-code, so its absence is a
+    # sidecar defect we can name. A cloud, local or Orca-cloud preset carries
+    # its own start G-code, and the embedded-settings fallback prints the
+    # source file's — both are the user's to author, and refusing them here
+    # would be us second-guessing a profile we did not resolve.
+    if (
+        not used_embedded_settings
+        and request.printer_preset is not None
+        and request.printer_preset.source == "standard"
+        and start_gcode_is_missing(result.content, export_3mf=bool(request.export_3mf))
+    ):
+        logger.error(
+            "Slice for printer preset %r came back without start G-code (%s); refusing it",
+            request.printer_preset.id,
+            "3mf" if request.export_3mf else "gcode",
+        )
+        raise HTTPException(status_code=502, detail=missing_start_gcode_message(request.printer_preset.id))
+
     return result, used_embedded_settings
 
 

+ 95 - 0
backend/app/services/slice_output_check.py

@@ -0,0 +1,95 @@
+"""Sanity-check a sliced file before Bambuddy is willing to print it.
+
+A Bambu printer's start G-code is where the AMS load (``M620``) and the
+preparation-stage announcements (``M1002 gcode_claim_action``) live. Slice
+without it and the job still dispatches, still heats the bed and still moves
+the toolhead — it simply extrudes nothing, reports no stage, and sits at layer
+0 until someone notices (#2838).
+
+Nothing downstream can tell that apart from a print that has not started yet,
+so the only place to catch it is here, on the bytes the slicer just produced.
+All 56 instantiable presets in the shipped Bambu bundle carry
+``gcode_claim_action``, which makes its absence a reliable signal rather than
+a heuristic.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import logging
+import zipfile
+
+logger = logging.getLogger(__name__)
+
+# Present in the start G-code of every instantiable machine preset in the
+# bundle. `M620` is equally universal today, but this one is the marker whose
+# absence the reporter could see from the printer's side: no claim actions
+# means `stg_cur` stays -1 and the UI never names a preparation step.
+_START_GCODE_MARKER = "gcode_claim_action"
+
+_PROJECT_SETTINGS = "Metadata/project_settings.config"
+
+# The start block sits after the file header and the embedded thumbnails, well
+# inside this. Bounded so a pathological output cannot turn the check into a
+# multi-hundred-megabyte read.
+_GCODE_SCAN_BYTES = 4 * 1024 * 1024
+
+
+def _as_text(value: object) -> str:
+    """Slicer config values arrive as a bare string or a one-element list."""
+    if isinstance(value, list):
+        return "".join(str(v) for v in value)
+    return "" if value is None else str(value)
+
+
+def start_gcode_is_missing(content: bytes, *, export_3mf: bool) -> bool:
+    """Whether ``content`` was sliced without the printer's start G-code.
+
+    Answers False whenever the question cannot be settled — an unreadable
+    archive, a missing config, a decode failure. A slice that is merely
+    unusual must not be blocked by a check that only knows how to recognise
+    one specific defect; the caller has no better information than we do.
+    """
+    if not content:
+        return False
+
+    if not export_3mf:
+        head = content[:_GCODE_SCAN_BYTES].decode("utf-8", errors="ignore")
+        return bool(head) and _START_GCODE_MARKER not in head
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(content)) as archive:
+            raw = archive.read(_PROJECT_SETTINGS)
+    except (KeyError, OSError, zipfile.BadZipFile) as exc:
+        logger.debug("Slice output check skipped: cannot read %s (%s)", _PROJECT_SETTINGS, exc)
+        return False
+
+    try:
+        settings = json.loads(raw)
+    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+        logger.debug("Slice output check skipped: %s is not valid JSON (%s)", _PROJECT_SETTINGS, exc)
+        return False
+
+    if not isinstance(settings, dict) or "machine_start_gcode" not in settings:
+        logger.debug("Slice output check skipped: no machine_start_gcode in %s", _PROJECT_SETTINGS)
+        return False
+
+    return _START_GCODE_MARKER not in _as_text(settings["machine_start_gcode"])
+
+
+def missing_start_gcode_message(printer_preset_name: str) -> str:
+    """The 502 body for a slice that came back without its start G-code.
+
+    Names the sidecar because that is where the fix is: Bambuddy sends the
+    bundled preset by name and the sidecar resolves it, so an older image
+    resolves it to a generic 577-character stub and no amount of retrying in
+    Bambuddy will change the result.
+    """
+    return (
+        f"The slicer returned a file with no printer start G-code for '{printer_preset_name}'. "
+        "Printing it would heat the printer and extrude nothing, so it was not saved. "
+        "This is fixed by updating the slicer sidecar image: older ones cannot read the "
+        "companion profile that holds the real start G-code for most Bambu printers. "
+        "Update the sidecar and slice again."
+    )

+ 148 - 0
backend/tests/unit/test_slice_backstop_wiring_2838.py

@@ -0,0 +1,148 @@
+"""``_run_slicer_with_fallback`` refuses a start-G-code-less slice (#2838).
+
+The check itself is pinned in ``test_slice_output_check_2838.py``. This covers
+where it is wired: which slices it judges and which it lets past. Getting that
+scope wrong in either direction is worse than the defect — too narrow and the
+in-air print still ships, too wide and a user's own printer profile with a
+hand-written start block stops slicing.
+"""
+
+import io
+import json
+import zipfile
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.api.routes.library import _run_slicer_with_fallback
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+
+pytestmark = pytest.mark.unit
+
+GENERIC_START = "M17 X1.2 Y1.2 Z0.75\nG28 X\nM104 S140\n"
+REAL_START = "M1002 gcode_claim_action : 1\nM620 M\nM620.10 A0 F74.8347 H0.4 C\n"
+
+
+def _sliced_3mf(start_gcode: str) -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as archive:
+        archive.writestr("3D/3dmodel.model", "<model/>")
+        archive.writestr(
+            "Metadata/project_settings.config",
+            json.dumps({"machine_start_gcode": start_gcode}),
+        )
+    return buffer.getvalue()
+
+
+def _source_3mf() -> bytes:
+    """A source file complete enough for the wrapper's 3MF pre-processing.
+
+    The embedded-settings fallback is 3MF-only — there is nothing for an STL
+    to fall back *to* — so that case cannot be driven with a plain model.
+    """
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+        archive.writestr("3D/3dmodel.model", "<model/>")
+        archive.writestr("Metadata/project_settings.config", json.dumps({"layer_height": "0.2"}))
+        archive.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
+        archive.writestr(
+            "Metadata/slice_info.config",
+            "<config><plate><metadata key='index' value='1'/></plate></config>",
+        )
+    return buffer.getvalue()
+
+
+def _request(printer_source: str) -> SliceRequest:
+    return SliceRequest(
+        printer_preset=PresetRef(source=printer_source, id="Bambu Lab X2D 0.4 nozzle"),
+        process_preset=PresetRef(source="standard", id="0.20mm Standard @BBL X2D"),
+        filament_presets=[PresetRef(source="standard", id="Bambu PLA Basic @BBL X2D")],
+        export_3mf=True,
+    )
+
+
+async def _run(
+    request: SliceRequest,
+    *,
+    start_gcode: str,
+    embedded_fallback: bool = False,
+):
+    """Drive the wrapper with a sidecar that returns exactly these bytes."""
+    from backend.app.services import slicer_api as slicer_api_module
+
+    result = slicer_api_module.SliceResult(
+        content=_sliced_3mf(start_gcode),
+        print_time_seconds=600,
+        filament_used_g=12.0,
+        filament_used_mm=4000.0,
+    )
+
+    service = MagicMock()
+    service.close = AsyncMock()
+    if embedded_fallback:
+        # The real fallback: the CLI dies on the --load-settings path, so the
+        # slice is re-run against the settings baked into the source file.
+        # A generic failure on purpose — a message that reads as a content
+        # rejection is surfaced instead of retried.
+        service.slice_with_profiles = AsyncMock(side_effect=slicer_api_module.SlicerApiServerError("boom"))
+        service.slice_without_profiles = AsyncMock(return_value=result)
+    else:
+        service.slice_with_profiles = AsyncMock(return_value=result)
+        service.slice_without_profiles = AsyncMock()
+
+    async def _setting(_db, key):
+        return {"preferred_slicer": "bambu_studio", "bambu_studio_api_url": "http://sidecar:3000"}.get(key)
+
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(side_effect=_setting)),
+        patch(
+            "backend.app.services.preset_resolver.resolve_preset_ref",
+            new=AsyncMock(return_value=json.dumps({"name": "x", "from": "system", "type": "machine"})),
+        ),
+        patch.object(slicer_api_module, "SlicerApiService", return_value=service),
+        patch.object(slicer_api_module, "get_stall_timeout_seconds", new=AsyncMock(return_value=60.0)),
+    ):
+        return await _run_slicer_with_fallback(
+            SimpleNamespace(get=AsyncMock(return_value=None)),
+            model_bytes=_source_3mf() if embedded_fallback else b"solid cube\nendsolid cube\n",
+            model_filename="cube.3mf" if embedded_fallback else "cube.stl",
+            request=request,
+            current_user_id=None,
+        )
+
+
+class TestItRefusesTheDefect:
+    async def test_a_standard_preset_with_no_start_gcode_is_refused(self):
+        with pytest.raises(HTTPException) as exc:
+            await _run(_request("standard"), start_gcode=GENERIC_START)
+
+        assert exc.value.status_code == 502
+        assert "Bambu Lab X2D 0.4 nozzle" in exc.value.detail
+        assert "sidecar" in exc.value.detail
+
+    async def test_the_same_slice_with_real_start_gcode_goes_through(self):
+        result, used_embedded = await _run(_request("standard"), start_gcode=REAL_START)
+
+        assert used_embedded is False
+        assert result.print_time_seconds == 600
+
+
+class TestItDoesNotJudgeProfilesBambuddyDidNotResolve:
+    """The bundle is what makes the absence conclusive. Outside it, the start
+    block is the user's to author and an empty one may well be deliberate."""
+
+    @pytest.mark.parametrize("source", ["local", "cloud", "orca_cloud"])
+    async def test_other_tiers_slice_normally(self, source):
+        result, _ = await _run(_request(source), start_gcode=GENERIC_START)
+
+        assert result.print_time_seconds == 600
+
+    async def test_the_embedded_settings_fallback_is_left_alone(self):
+        """That path prints the source file's own settings — the preset we
+        picked was never applied, so it cannot be the thing at fault."""
+        result, used_embedded = await _run(_request("standard"), start_gcode=GENERIC_START, embedded_fallback=True)
+
+        assert used_embedded is True
+        assert result.print_time_seconds == 600

+ 117 - 0
backend/tests/unit/test_slice_output_check_2838.py

@@ -0,0 +1,117 @@
+"""A slice with no printer start G-code must not become a print (#2838).
+
+The start block is where a Bambu printer's AMS load (``M620``) and its
+preparation-stage announcements (``M1002 gcode_claim_action``) live. Sliced
+without it, a job dispatches and looks alive — bed at temperature, toolhead
+moving — while extruding nothing and reporting no stage, which is
+indistinguishable from a print that simply has not started yet.
+
+The defect that produced such files was in the sidecar: Bambuddy sends a
+bundled preset by name, and a resolver that walks only ``inherits`` never
+finds the companion file holding the real start G-code, falling through to a
+577-character generic stub. Bambuddy cannot see that resolution happen, so
+this checks the one thing it can see — the bytes that came back.
+"""
+
+import io
+import json
+import zipfile
+
+import pytest
+
+from backend.app.services.slice_output_check import (
+    missing_start_gcode_message,
+    start_gcode_is_missing,
+)
+
+pytestmark = pytest.mark.unit
+
+# Abridged from `fdm_machine_common`, the root template every Bambu machine
+# preset falls through to when the companion is not read. What matters is
+# what it lacks.
+GENERIC = "M17 X1.2 Y1.2 Z0.75\nG28 X\nM104 S140\nG29.2 S0\n"
+
+REAL = "M1002 gcode_claim_action : 1\nM620 M\nM620.10 A0 F74.8347 H0.4 C\nM1002 gcode_claim_action : 14\n"
+
+
+def _3mf(settings: dict | None, *, valid_zip: bool = True) -> bytes:
+    if not valid_zip:
+        return b"not a zip file at all"
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as archive:
+        archive.writestr("Metadata/plate_1.gcode", "; sliced\nG1 X0 Y0\n")
+        if settings is not None:
+            archive.writestr("Metadata/project_settings.config", json.dumps(settings))
+    return buffer.getvalue()
+
+
+class TestTheDefect:
+    def test_the_generic_fallback_is_caught(self):
+        assert start_gcode_is_missing(_3mf({"machine_start_gcode": GENERIC}), export_3mf=True)
+
+    def test_a_real_start_block_passes(self):
+        assert not start_gcode_is_missing(_3mf({"machine_start_gcode": REAL}), export_3mf=True)
+
+    def test_the_value_may_be_a_list(self):
+        """Slicer config values arrive as a bare string or a one-element list
+        depending on the setting's declared type; both shapes are real."""
+        assert not start_gcode_is_missing(_3mf({"machine_start_gcode": [REAL]}), export_3mf=True)
+        assert start_gcode_is_missing(_3mf({"machine_start_gcode": [GENERIC]}), export_3mf=True)
+
+    def test_an_empty_start_block_is_caught(self):
+        assert start_gcode_is_missing(_3mf({"machine_start_gcode": ""}), export_3mf=True)
+
+
+class TestPlainGcodeExport:
+    """Not every slice exports a 3MF, and a raw .gcode file has no config to
+    read — the emitted text is all there is."""
+
+    def test_a_real_start_block_passes(self):
+        assert not start_gcode_is_missing(f"; header\n{REAL}G1 X0\n".encode(), export_3mf=False)
+
+    def test_the_generic_fallback_is_caught(self):
+        assert start_gcode_is_missing(f"; header\n{GENERIC}G1 X0\n".encode(), export_3mf=False)
+
+    def test_undecodable_bytes_do_not_crash_it(self):
+        """Thumbnails and comments can carry anything; the marker is ASCII."""
+        assert not start_gcode_is_missing(b"\xff\xfe binary junk " + REAL.encode(), export_3mf=False)
+
+
+class TestItDeclinesToJudgeWhatItCannotRead:
+    """Blocking a slice is a strong action. Anything this check cannot settle
+    has to pass — the caller knows no more than it does, and refusing an
+    unusual-but-fine file would be worse than the defect it guards against."""
+
+    def test_an_unreadable_archive_passes(self):
+        assert not start_gcode_is_missing(_3mf(None, valid_zip=False), export_3mf=True)
+
+    def test_an_archive_without_the_config_passes(self):
+        assert not start_gcode_is_missing(_3mf(None), export_3mf=True)
+
+    def test_a_config_without_the_key_passes(self):
+        """A slicer that does not write the key at all is not evidence that
+        the printer will get no start G-code."""
+        assert not start_gcode_is_missing(_3mf({"layer_height": "0.2"}), export_3mf=True)
+
+    def test_a_malformed_config_passes(self):
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as archive:
+            archive.writestr("Metadata/project_settings.config", "{ not json")
+        assert not start_gcode_is_missing(buffer.getvalue(), export_3mf=True)
+
+    def test_empty_content_passes(self):
+        assert not start_gcode_is_missing(b"", export_3mf=True)
+        assert not start_gcode_is_missing(b"", export_3mf=False)
+
+
+class TestTheMessage:
+    def test_it_names_the_preset_and_the_actual_fix(self):
+        message = missing_start_gcode_message("Bambu Lab X2D 0.4 nozzle")
+
+        assert "Bambu Lab X2D 0.4 nozzle" in message
+        # The fix is a sidecar image, not anything the user can change in
+        # Bambuddy — saying so is the whole point of failing loudly.
+        assert "sidecar" in message
+
+    def test_it_says_the_file_was_not_kept(self):
+        assert "not saved" in missing_start_gcode_message("Bambu Lab P1S 0.4 nozzle")

Неке датотеке нису приказане због велике количине промена