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.
This commit is contained in:
maziggy
2026-08-15 11:37:01 +02:00
parent 214617c3ea
commit f3b6a503bd
5 changed files with 383 additions and 0 deletions
+2
View File
@@ -33,6 +33,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
- **A server-side slice came out with no printer start G-code, so the print ran in air (#2838, reported by @lonix)** — A job sliced in Bambuddy and sent to a Bambu printer dispatched normally, heated the bed to target and moved the toolhead, then extruded nothing: the layer counter stayed at 0, the AMS never loaded, and the printer named no preparation stage. **This requires updating the slicer sidecar image, not just Bambuddy** — the defect was in the sidecar's profile resolver and that is where the fix lives. A printer's start G-code is not a small thing; on an X2D it is 14.5 KB carrying 22 `M1002 gcode_claim_action` calls and 18 `M620` lines including the AMS load macros. What arrived instead was a 577-character generic block inherited from `fdm_machine_common`, which is why the `ams_mapping` Bambuddy sends was correct and made no difference — the mapping says which tray backs which slot, but with no `M620` the printer is never asked to load anything, and with no claim actions it reports no stage. The cause is a layout in Bambu's own profile bundle that the resolver did not know about: a preset can keep a setting in a companion file named `<preset> template <setting>.json`, and **nothing in the preset references it** — the desktop slicer finds it by name. Walking only `inherits`, as the sidecar did, never reaches it. This was not one printer's problem. Every one of the 56 instantiable Bambu machine presets omits its start G-code from its own file and from every ancestor but the generic root, so **X1, X1C, X1E, P1P, P1S, P2S, A1, A1 mini, A2L, H2C, H2D, H2D Pro, H2S and X2D were all affected**; 29 ship the real block in a companion and the other 27 are the 0.2/0.6/0.8 nozzle variants, which reach their 0.4 sibling's companion through the inheritance walk. The same layout carries `machine_end_gcode`, `change_filament_gcode`, `layer_change_gcode` and `time_lapse_gcode`, all of which were being lost the same way. The resolver now reads a preset's companions alongside the preset itself, at that preset's own precedence — so a value you set yourself still wins, and the model's real block still beats the generic root. It discovers the companions by listing rather than by a hardcoded list of settings, so a new one in a future Bambu bundle is picked up without another fix. Verified against the shipped bundle rather than fixtures: all 56 presets now resolve to their own start G-code, and a test that walks the real bundle inside the sidecar image guards it against the next slicer release reorganising the profile tree. **Multi-colour prints hid this**, which is why it went unreported for so long — the `M620.10` tool-change macros are emitted per filament change from `change_filament_gcode`, independently of the start block, so a multi-colour file looks AMS-enabled while still missing everything the start block does. Single-filament plates showed it plainly. Bambuddy has also gained a backstop of its own, described below.
- **Bambuddy now refuses to save a slice that has no printer start G-code** — The failure above was undetectable from the outside: the file looked like any other slice, the print dispatched, and a printer preparing normally and a printer doing nothing look identical for the first few minutes. Three dispatches across two code paths and two Bambuddy versions went by before the cause was found. A slice that Bambuddy asked to be made against a bundled Bambu preset must come back with the preparation-stage markers that every preset in the bundle carries; when it does not, the slice now fails with a message naming the printer preset and saying that the slicer sidecar image needs updating, instead of being written to the library and queued. It is deliberately narrow. Only the standard tier is judged — that is the one case where Bambuddy knows what the output has to contain, because it asked for a preset whose content is fixed. A profile from Bambu Cloud, Orca Cloud or your own local presets carries its own start G-code and is yours to author, including leaving it empty; and the embedded-settings fallback prints the source file's own configuration, which the preset we picked never touched. Anything the check cannot read — an archive it cannot open, a config without the field — passes, on the grounds that blocking a slice is a strong action and an unfamiliar-but-working file must not be caught by a check that only knows how to recognise one specific defect. Covered by backend tests.
- **The Manage Archives API-key toggle said it could not purge statistics, and it can** — The description under the checkbox in **Settings → API Keys** promised that a key with this scope deletes archives but leaves their filament, energy, time and cost contribution in Quick Stats. That is not what the route does: `DELETE /archives/{id}?purge_stats=true` is gated by the same archive-delete permission, so a key with Manage Archives has always been able to drop the statistics along with the print. Only the separate bulk-purge endpoints are administrative. Anyone who read that line while deciding what to hand an automation was told the opposite of the truth, so the description now says the contribution goes too. The permissions themselves are unchanged — this is the description catching up with the behaviour, in all thirteen languages. In the same pass the **Read Status**, **Manage Queue** and **Manage Library** descriptions were brought up to date: the first still described itself as printer status and queue only, and none of the three mentioned slicing or pipelines.
- **The Printers page forgot its status and location filters as soon as you left the page (#2833, reported by @RambachTJ)** — Pick a location, go to Archives, come back, and every printer was showing again. Both filters were the only preferences on that page that were not remembered — sort order, card size, view mode, collapsed sections and hide-disconnected all were — so this was an omission rather than a decision. They now persist with the rest. A remembered filter that no longer matches anything resets itself instead: the location dropdown is only shown while at least one printer has a location, so a location that was later renamed or removed would otherwise hide every printer *and* the control that would bring them back. The search box is still deliberately not remembered — one that silently refilled itself on return would be a surprise rather than a convenience. Covered by frontend tests.
- **Re-slicing a model whose name contains a slash failed with "No such file or directory" (#2832, reported by @SanderRutten)** — A print's name is taken from inside the 3MF rather than from the filename, so a MakerWorld title like "Planter Pot with Drip Tray, 12 cm / 5 inches" arrives complete with its punctuation. The slice output was then filed in a folder named after it, and a slash in a folder name is not a character — it is another level of folder. The level got created, the file underneath it did not, and the slice failed on a path that looks like it exists. Renaming the print first was the only way through, which is a strange thing to have to know. Names are now reduced to something a folder can be called before they are used as one, with the characters that cannot appear in a name replaced rather than dropped, so the folder still reads like the model's title. The name shown in Bambuddy is untouched and keeps its punctuation. Windows installs are covered by the same change: the reserved characters there include the colon, which "Model: v2" has and which fails in exactly the same way. Covered by backend tests.
+21
View File
@@ -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
@@ -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."
)
@@ -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
@@ -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")