slice_output_check.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """Sanity-check a sliced file before Bambuddy is willing to print it.
  2. A Bambu printer's start G-code is where the AMS load (``M620``) and the
  3. preparation-stage announcements (``M1002 gcode_claim_action``) live. Slice
  4. without it and the job still dispatches, still heats the bed and still moves
  5. the toolhead — it simply extrudes nothing, reports no stage, and sits at layer
  6. 0 until someone notices (#2838).
  7. Nothing downstream can tell that apart from a print that has not started yet,
  8. so the only place to catch it is here, on the bytes the slicer just produced.
  9. All 56 instantiable presets in the shipped Bambu bundle carry
  10. ``gcode_claim_action``, which makes its absence a reliable signal rather than
  11. a heuristic.
  12. ``unresolved_filament_slots`` covers a quieter failure found while
  13. investigating #2977: a filament profile whose name the sidecar's bundle
  14. cannot resolve is not rejected. The CLI inherits nothing, falls back to its
  15. compiled-in defaults for every field, and returns a perfectly well-formed
  16. success. Measured against a 02.08.02.61 sidecar, a profile named for a preset
  17. that does not exist slices as ``filament_type: ["PLA"]`` at
  18. ``nozzle_temperature: ["200"]`` with ``filament_ids: [""]`` and
  19. ``filament_vendor: ["(Undefined)"]`` — so a PETG preset that fails to resolve
  20. prints at PLA temperatures. Unlike the missing start G-code this does not make
  21. the file unprintable, only wrong, so it is reported as a warning and the slice
  22. is kept.
  23. """
  24. from __future__ import annotations
  25. import io
  26. import json
  27. import logging
  28. import zipfile
  29. logger = logging.getLogger(__name__)
  30. # Present in the start G-code of every instantiable machine preset in the
  31. # bundle. `M620` is equally universal today, but this one is the marker whose
  32. # absence the reporter could see from the printer's side: no claim actions
  33. # means `stg_cur` stays -1 and the UI never names a preparation step.
  34. _START_GCODE_MARKER = "gcode_claim_action"
  35. _PROJECT_SETTINGS = "Metadata/project_settings.config"
  36. # The start block sits after the file header and the embedded thumbnails, well
  37. # inside this. Bounded so a pathological output cannot turn the check into a
  38. # multi-hundred-megabyte read.
  39. _GCODE_SCAN_BYTES = 4 * 1024 * 1024
  40. def _as_text(value: object) -> str:
  41. """Slicer config values arrive as a bare string or a one-element list."""
  42. if isinstance(value, list):
  43. return "".join(str(v) for v in value)
  44. return "" if value is None else str(value)
  45. def start_gcode_is_missing(content: bytes, *, export_3mf: bool) -> bool:
  46. """Whether ``content`` was sliced without the printer's start G-code.
  47. Answers False whenever the question cannot be settled — an unreadable
  48. archive, a missing config, a decode failure. A slice that is merely
  49. unusual must not be blocked by a check that only knows how to recognise
  50. one specific defect; the caller has no better information than we do.
  51. """
  52. if not content:
  53. return False
  54. if not export_3mf:
  55. head = content[:_GCODE_SCAN_BYTES].decode("utf-8", errors="ignore")
  56. return bool(head) and _START_GCODE_MARKER not in head
  57. try:
  58. with zipfile.ZipFile(io.BytesIO(content)) as archive:
  59. raw = archive.read(_PROJECT_SETTINGS)
  60. except (KeyError, OSError, zipfile.BadZipFile) as exc:
  61. logger.debug("Slice output check skipped: cannot read %s (%s)", _PROJECT_SETTINGS, exc)
  62. return False
  63. try:
  64. settings = json.loads(raw)
  65. except (UnicodeDecodeError, json.JSONDecodeError) as exc:
  66. logger.debug("Slice output check skipped: %s is not valid JSON (%s)", _PROJECT_SETTINGS, exc)
  67. return False
  68. if not isinstance(settings, dict) or "machine_start_gcode" not in settings:
  69. logger.debug("Slice output check skipped: no machine_start_gcode in %s", _PROJECT_SETTINGS)
  70. return False
  71. return _START_GCODE_MARKER not in _as_text(settings["machine_start_gcode"])
  72. def missing_start_gcode_message(printer_preset_name: str) -> str:
  73. """The 502 body for a slice that came back without its start G-code.
  74. Names the sidecar because that is where the fix is: Bambuddy sends the
  75. bundled preset by name and the sidecar resolves it, so an older image
  76. resolves it to a generic 577-character stub and no amount of retrying in
  77. Bambuddy will change the result.
  78. """
  79. return (
  80. f"The slicer returned a file with no printer start G-code for '{printer_preset_name}'. "
  81. "Printing it would heat the printer and extrude nothing, so it was not saved. "
  82. "This is fixed by updating the slicer sidecar image: older ones cannot read the "
  83. "companion profile that holds the real start G-code for most Bambu printers. "
  84. "Update the sidecar and slice again."
  85. )
  86. # What the CLI writes into a filament slot it could not resolve. Bambu Studio
  87. # uses this literal for a filament whose vendor is unknown, and it is the one
  88. # field that separates "nothing inherited" from a legitimately vendor-less
  89. # profile: a resolved preset always carries a real ``filament_ids`` entry
  90. # (``GFL96`` for Generic PLA Silk, ``GFG99`` for Generic PETG), while an
  91. # unresolved one carries the empty string.
  92. _UNDEFINED_VENDOR = "(Undefined)"
  93. def unresolved_filament_slots(content: bytes, *, export_3mf: bool) -> list[int]:
  94. """1-indexed filament slots the slicer could not resolve a preset for.
  95. Empty whenever the question cannot be settled — a raw-G-code response (the
  96. per-slot config only exists in the 3MF), an unreadable archive, a missing
  97. or malformed config. Same principle as ``start_gcode_is_missing``: a check
  98. that recognises one specific defect must not report anything it has not
  99. actually seen.
  100. Both signals are required together. ``filament_vendor`` alone would flag a
  101. hand-written profile that simply never named a vendor, and ``filament_ids``
  102. alone would flag a user's own cloud preset, which legitimately carries no
  103. bundled filament id. A slot that has neither inherited a vendor nor been
  104. given an id is one where the ``inherits:`` target did not exist.
  105. """
  106. if not content or not export_3mf:
  107. return []
  108. try:
  109. with zipfile.ZipFile(io.BytesIO(content)) as archive:
  110. raw = archive.read(_PROJECT_SETTINGS)
  111. settings = json.loads(raw)
  112. except (KeyError, OSError, zipfile.BadZipFile, UnicodeDecodeError, json.JSONDecodeError) as exc:
  113. logger.debug("Filament resolution check skipped: cannot read %s (%s)", _PROJECT_SETTINGS, exc)
  114. return []
  115. if not isinstance(settings, dict):
  116. return []
  117. vendors = settings.get("filament_vendor")
  118. ids = settings.get("filament_ids")
  119. if not isinstance(vendors, list) or not isinstance(ids, list):
  120. logger.debug("Filament resolution check skipped: no per-slot vendor/id arrays")
  121. return []
  122. unresolved: list[int] = []
  123. for slot in range(min(len(vendors), len(ids))):
  124. if _as_text(vendors[slot]).strip() == _UNDEFINED_VENDOR and not _as_text(ids[slot]).strip():
  125. unresolved.append(slot + 1)
  126. return unresolved
  127. def unresolved_filament_message(slots: list[int], preset_names: list[str]) -> str:
  128. """The warning logged for slots whose filament preset did not resolve.
  129. Names the presets by the slot they were picked for, because the user picked
  130. them per slot and that is the only handle they have on which dropdown to
  131. change.
  132. """
  133. parts: list[str] = []
  134. for slot in slots:
  135. name = preset_names[slot - 1] if slot - 1 < len(preset_names) else ""
  136. parts.append(f"slot {slot} ({name})" if name else f"slot {slot}")
  137. return (
  138. f"The slicer could not resolve the filament preset for {', '.join(parts)}, so those slots "
  139. "were sliced with its built-in defaults (PLA, 200 C) instead of the preset's own settings. "
  140. "The file was kept, but check the temperatures before printing. This usually means the "
  141. "slicer sidecar's bundled profiles do not contain the preset that was picked - updating "
  142. "the sidecar image, or picking a preset from its own bundled list, resolves it."
  143. )