Explorar el Código

● fix(#1111): advance queue item when print fails before reaching RUNNING

  When a file sliced for the wrong nozzle size is dispatched, the printer
  goes IDLE -> PREPARE -> FAILED without ever entering RUNNING. Completion
  detection required prev=RUNNING or _was_running=True, so on_print_complete
  never fired and the queue item stayed at "printing" forever -- blocking
  every subsequent pending item for that printer (check_queue seeds
  busy_printers from any row in 'printing').

  Fire completion on FAILED from PREPARE or SLICING too. Restricted to
  those two pre-print states so a stale FAILED on first connection
  (prev=None) still can't accidentally advance an unrelated queue item.

  Also populate PrintQueueItem.error_message from the current HMS error
  list via the existing hms_errors.py lookup, so users see e.g.
  "[0500_4038] The nozzle diameter in sliced file is not consistent
  with the current nozzle setting" instead of a blank failure reason.
maziggy hace 1 mes
padre
commit
08601b4772

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 29 - 0
backend/app/main.py

@@ -437,6 +437,33 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
 
 
+def _format_hms_error_summary(hms_errors: list[dict]) -> str | None:
+    """Build a human-readable failure reason from MQTT hms_errors for PrintQueueItem.error_message.
+
+    Each entry has keys: code ('0x4038'), attr (32-bit int), module, severity.
+    The short code used for the hms_errors.py lookup table is 'MMMM_EEEE' — module
+    from attr bits 16-31, error from the numeric part of code. Falls back to the raw
+    short code when no description is on file. Returns None for an empty list so
+    callers can leave error_message unset.
+    """
+    if not hms_errors:
+        return None
+    from backend.app.services.hms_errors import get_error_description
+
+    parts: list[str] = []
+    for err in hms_errors:
+        try:
+            code_str = str(err.get("code", "")).replace("0x", "")
+            error_num = int(code_str, 16) if code_str else 0
+            module_num = (int(err.get("attr", 0)) >> 16) & 0xFFFF
+            short_code = f"{module_num:04X}_{error_num:04X}"
+        except (TypeError, ValueError):
+            continue
+        description = get_error_description(short_code)
+        parts.append(f"[{short_code}] {description}" if description else f"[{short_code}]")
+    return "; ".join(parts) if parts else None
+
+
 async def _bump_library_file_usage_if_completed(db, item, queue_status: str) -> None:
     """Increment LibraryFile.print_count and stamp last_printed_at when a queued
     print completes successfully. Gated to status=='completed': failed, cancelled
@@ -2672,6 +2699,8 @@ async def on_print_complete(printer_id: int, data: dict):
                     queue_status = "cancelled"
                 item.status = queue_status
                 item.completed_at = datetime.now(timezone.utc)
+                if queue_status == "failed" and not item.error_message:
+                    item.error_message = _format_hms_error_summary(data.get("hms_errors") or [])
 
                 # Bump usage counters on the source library file so admins can
                 # sort by "last printed" and (eventually) auto-purge stale

+ 7 - 0
backend/app/services/bambu_mqtt.py

@@ -2669,6 +2669,13 @@ class BambuMQTTClient:
             and (
                 self._previous_gcode_state == "RUNNING"  # Normal transition
                 or (self._was_running and self._previous_gcode_state != self.state.state)  # After server restart
+                # Pre-print failure (#1111): printer rejected the job during setup
+                # — wrong nozzle size, AMS error, etc. The print never reaches
+                # RUNNING, so without this branch neither the RUNNING check nor
+                # _was_running match and the queue item stays stuck at "printing".
+                # Restricted to FAILED from pre-print states so a stale FAILED on
+                # first connection (prev=None) still can't accidentally fire.
+                or (self.state.state == "FAILED" and self._previous_gcode_state in ("PREPARE", "SLICING"))
             )
         )
         # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)

+ 136 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -427,6 +427,142 @@ class TestRealisticMessageFlow:
         assert complete_data["status"] == "failed"
 
 
+class TestPrePrintFailureCompletion:
+    """Tests for completion detection when the print errors before reaching RUNNING (#1111).
+
+    Common trigger: a file sliced for the wrong nozzle diameter is dispatched. The
+    printer transitions IDLE -> PREPARE -> FAILED without ever entering RUNNING, so
+    the legacy completion detection (which required _previous_gcode_state == 'RUNNING'
+    or _was_running == True) left the queue item stuck at 'printing' forever.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_prepare_to_failed_triggers_completion(self, mqtt_client):
+        """PREPARE -> FAILED must fire on_print_complete (wrong nozzle size etc.)."""
+        complete_data = {}
+        mqtt_client.on_print_start = lambda data: None
+        mqtt_client.on_print_complete = lambda data: complete_data.update(data)
+
+        mqtt_client._previous_gcode_state = "PREPARE"
+        mqtt_client._was_running = False
+        mqtt_client._completion_triggered = False
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "gcode_file": "/data/Metadata/plate_1.gcode",
+                    "subtask_name": "WrongNozzle",
+                }
+            }
+        )
+
+        assert complete_data.get("status") == "failed"
+
+    def test_slicing_to_failed_triggers_completion(self, mqtt_client):
+        """SLICING -> FAILED also treated as a pre-print failure."""
+        complete_data = {}
+        mqtt_client.on_print_start = lambda data: None
+        mqtt_client.on_print_complete = lambda data: complete_data.update(data)
+
+        mqtt_client._previous_gcode_state = "SLICING"
+        mqtt_client._was_running = False
+        mqtt_client._completion_triggered = False
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "gcode_file": "/data/Metadata/plate_1.gcode",
+                    "subtask_name": "WrongNozzle",
+                }
+            }
+        )
+
+        assert complete_data.get("status") == "failed"
+
+    def test_initial_failed_does_not_trigger_completion(self, mqtt_client):
+        """First message arriving with FAILED (no prior state) must NOT fire completion.
+
+        Protects against a stale FAILED on reconnect being mistaken for a fresh failure
+        and marking an unrelated queue item as failed.
+        """
+        calls = []
+        mqtt_client.on_print_start = lambda data: None
+        mqtt_client.on_print_complete = lambda data: calls.append(data)
+
+        assert mqtt_client._previous_gcode_state is None
+        assert mqtt_client._was_running is False
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "gcode_file": "/data/Metadata/plate_1.gcode",
+                    "subtask_name": "Stale",
+                }
+            }
+        )
+
+        assert calls == []
+
+    def test_idle_to_failed_does_not_trigger_completion(self, mqtt_client):
+        """IDLE -> FAILED (no print ever dispatched) must NOT fire completion."""
+        calls = []
+        mqtt_client.on_print_start = lambda data: None
+        mqtt_client.on_print_complete = lambda data: calls.append(data)
+
+        mqtt_client._previous_gcode_state = "IDLE"
+        mqtt_client._was_running = False
+        mqtt_client._completion_triggered = False
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "subtask_name": "Stale",
+                }
+            }
+        )
+
+        assert calls == []
+
+    def test_prepare_to_failed_includes_hms_errors_in_callback(self, mqtt_client):
+        """Pre-print FAILED callback should carry the current HMS error list so the
+        queue handler can populate a meaningful error_message."""
+        complete_data = {}
+        mqtt_client.on_print_start = lambda data: None
+        mqtt_client.on_print_complete = lambda data: complete_data.update(data)
+
+        mqtt_client._previous_gcode_state = "PREPARE"
+        mqtt_client._was_running = False
+
+        # Message carries HMS data for a nozzle-size mismatch (0500_4038) and the
+        # PREPARE -> FAILED gcode_state transition in a single update.
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "gcode_file": "/data/Metadata/plate_1.gcode",
+                    "hms": [{"attr": 0x05000000, "code": 0x4038}],
+                }
+            }
+        )
+
+        assert complete_data.get("status") == "failed"
+        errs = complete_data.get("hms_errors") or []
+        assert any(e.get("code") == "0x4038" for e in errs)
+
+
 class TestAMSDataMerging:
     """Tests for AMS data merging, particularly handling empty slots."""
 

+ 54 - 0
backend/tests/unit/test_hms_error_summary.py

@@ -0,0 +1,54 @@
+"""Tests for main._format_hms_error_summary — the helper that turns MQTT hms_errors
+into a human-readable PrintQueueItem.error_message on pre-print failures (#1111)."""
+
+
+def _format(hms_errors):
+    from backend.app.main import _format_hms_error_summary
+
+    return _format_hms_error_summary(hms_errors)
+
+
+def test_returns_none_for_empty_list():
+    assert _format([]) is None
+    assert _format(None or []) is None
+
+
+def test_formats_known_nozzle_mismatch_code():
+    """0500_4038 is the nozzle-size-mismatch code from the HMS table — the common
+    trigger for issue #1111."""
+    summary = _format([{"code": "0x4038", "attr": 0x05000000, "module": 0x5, "severity": 1}])
+    assert summary is not None
+    assert "0500_4038" in summary
+    assert "nozzle diameter" in summary.lower()
+
+
+def test_formats_unknown_code_as_bare_short_code():
+    summary = _format([{"code": "0x9999", "attr": 0x99990000, "module": 0x99, "severity": 1}])
+    assert summary == "[9999_9999]"
+
+
+def test_joins_multiple_errors_with_semicolons():
+    summary = _format(
+        [
+            {"code": "0x4038", "attr": 0x05000000, "module": 0x5, "severity": 1},
+            {"code": "0x9999", "attr": 0x99990000, "module": 0x99, "severity": 1},
+        ]
+    )
+    assert summary is not None
+    assert "; " in summary
+    assert summary.count("[") == 2
+
+
+def test_tolerates_malformed_entry_and_skips_it():
+    summary = _format(
+        [
+            {"code": "not-hex", "attr": "also-not-int"},
+            {"code": "0x4038", "attr": 0x05000000, "module": 0x5, "severity": 1},
+        ]
+    )
+    assert summary is not None
+    assert "0500_4038" in summary
+
+
+def test_all_malformed_returns_none():
+    assert _format([{"code": "not-hex", "attr": "also-not-int"}]) is None

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio