فهرست منبع

fix(printers): no-AMS printers print from the spool holder (issue #3356)

- A single-nozzle printer that reports no AMS ("ams": []) now gets
  use_ams=false whatever the mapping says. With nothing set on the
  spool holder the matcher resolved no mapping, so the mapping-based
  reconcile never ran and the print went out as use_ams=true, which
  the firmware refuses with 07FF_8012 "Failed to get AMS mapping
  table". A missing "ams" key (no AMS push handled yet) and
  dual-nozzle printers are left as they were.
- The MQTT Debug log keeps each message as it arrived. Entries were
  the same dict that processing kept as raw_data and wrote the merged
  AMS list into, so a push showed Bambuddy's state instead of the
  printer's AMS block (no ams_exist_bits, an unplugged AMS still
  listed), and later messages gained AMS data they never carried.
maziggy 1 روز پیش
والد
کامیت
0a7b7c6075
4فایلهای تغییر یافته به همراه131 افزوده شده و 16 حذف شده
  1. 2 0
      CHANGELOG.md
  2. 31 16
      backend/app/services/bambu_mqtt.py
  3. 20 0
      backend/tests/unit/services/test_bambu_mqtt.py
  4. 78 0
      backend/tests/unit/test_use_ams_no_ams_3356.py

+ 2 - 0
CHANGELOG.md

@@ -175,6 +175,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The MQTT Debug log shows what the printer sent** — Each logged message was the same object Bambuddy then processed, and processing writes Bambuddy's own state into it. An incoming push therefore showed Bambuddy's merged AMS list instead of the printer's AMS block, without fields such as `ams_exist_bits` and still listing an AMS that had been unplugged, and later messages could gain an AMS list they never carried. The log now keeps every incoming message exactly as it arrived.
+- **Printers without an AMS print from the spool holder (#3356, reported by @dogod3814t)** — A queue print to a printer with no AMS attached, such as a P1S on its spool holder alone, failed with "Failed to get AMS mapping table" when nothing was set on the spool holder. With nothing to match, the job went out without a filament mapping and asking the printer to use its AMS, which it refuses. Bambuddy now tells a printer that reports no AMS to print from the spool holder, as the slicer does. Dual-nozzle printers are unchanged.
 - **A personal cost center's budget can be changed (#3358, reported by @airforceixi)** — Saving the **Edit cost center** dialog on a user's personal cost center failed with "Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing", even when only the budget was changed, so a budget (or 0, to stop that user printing) could not be set. The dialog always sent the name as well, which a personal cost center refuses, and the budget was never saved. It now saves only the budget for a personal cost center, and its name field is greyed out, as the name is the user's.
 - **Slicing a painted multi-colour model keeps its colours (#3355, reported by @cartman75)** — On a model coloured with the paint tool, slicing in Bambuddy could turn several filaments into one colour, for example black, white, green and green all coming out green. Before slicing, Bambuddy works out which filaments the plate doesn't use and swaps them for one it does, so the slicer's material checks pass. It misread the painted areas, so painted filaments counted as unused and got another slot's profile and colour, and the print dialog then mapped them to that slot's spool. Painted areas are now read correctly, including small ones. A filament that really is unused keeps its own colour when it is swapped.
 - **An AMS Lite shows its serial number and firmware version (#3348, reported by @Spegeli)** — The printer reports both in its version answer, where the AMS Lite calls itself `ams_f1`. Bambuddy only knew the names of the other AMS models, so it skipped the AMS Lite's entry. The AMS popup showed "—" for both, the status API returned them empty, and the log warned on every connection that they were missing. They now come through on the A1, A1 Mini and A2L, including the A2L's AMS Lite, which the printer numbers differently. A name given to an AMS Lite before this, which was kept by its position because it had no serial number, still shows on the printer card and in the inventory's location column. Saving it again moves it to the serial number, so it follows the unit, and **Clear** now removes it; before, the old name came back after clearing.

+ 31 - 16
backend/app/services/bambu_mqtt.py

@@ -2065,6 +2065,23 @@ class BambuMQTTClient:
         if self.on_state_change:
             self.on_state_change(self.state)
 
+    def _log_message(self, topic: str, direction: str, raw: str) -> None:
+        """Record a message for the MQTT Debug log, as it arrived.
+
+        Parsed again from the raw text rather than shared with the handlers:
+        processing keeps the payload's "print" dict as the new raw_data and
+        writes Bambuddy's merged AMS list and other state into it, so a shared
+        dict showed Bambuddy's state instead of what the printer sent (#3356).
+        """
+        self._message_log.append(
+            MQTTLogEntry(
+                timestamp=datetime.now(timezone.utc).isoformat(),
+                topic=topic,
+                direction=direction,
+                payload=json.loads(raw),
+            )
+        )
+
     def _on_message(self, client, userdata, msg):
         for handler in self._raw_message_handlers:
             try:
@@ -2106,14 +2123,7 @@ class BambuMQTTClient:
                 # broker's echo, and the pair is itself evidence the command
                 # reached the broker.
                 if self._logging_enabled:
-                    self._message_log.append(
-                        MQTTLogEntry(
-                            timestamp=datetime.now(timezone.utc).isoformat(),
-                            topic=msg.topic,
-                            direction="out",
-                            payload=payload,
-                        )
-                    )
+                    self._log_message(msg.topic, "out", raw)
                 self._handle_request_message(payload)
                 return
 
@@ -2129,14 +2139,7 @@ class BambuMQTTClient:
 
             # Log message if logging is enabled
             if self._logging_enabled:
-                self._message_log.append(
-                    MQTTLogEntry(
-                        timestamp=datetime.now(timezone.utc).isoformat(),
-                        topic=msg.topic,
-                        direction="in",
-                        payload=payload,
-                    )
-                )
+                self._log_message(msg.topic, "in", raw)
             self._process_message(payload)
         except json.JSONDecodeError:
             pass  # Ignore non-JSON MQTT messages (e.g. binary or malformed payloads)
@@ -6420,6 +6423,18 @@ class BambuMQTTClient:
                         self.serial_number,
                     )
 
+            # (c) A single-nozzle printer that reported no AMS at all can only
+            #     print from the spool holder, whatever the mapping says. With
+            #     nothing set on the holder the matcher resolves no mapping, so
+            #     neither (a) nor (b) ran and the print went out as use_ams=True,
+            #     which the firmware refuses with 07FF_8012 "Failed to get AMS
+            #     mapping table" (#3356). An empty raw_data["ams"] is the
+            #     printer's own report of no AMS; a missing key means no AMS
+            #     push has been handled yet (a reconnect), which changes nothing.
+            if use_ams and not is_dual_nozzle and self.state.raw_data.get("ams") == []:
+                use_ams = False
+                logger.info("[%s] Printer has no AMS — setting use_ams=False (#3356)", self.serial_number)
+
             # Unique per-submission identity fields. Hardcoded "0" values caused
             # third-party MQTT observers (OctoEverywhere, etc.) to see reprints as
             # continuations of the same job: the printer reuses gcode_start_time

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

@@ -2087,6 +2087,26 @@ class TestRequestTopicIsCaptured:
         assert mqtt_client.get_logs() == []
         assert mqtt_client._captured_ams_mapping == [0, -1, -1, -1]
 
+    def test_a_push_is_logged_as_the_printer_sent_it(self, mqtt_client):
+        """Processing writes Bambuddy's merged AMS list into the payload's
+        "print" dict; the log must keep the printer's own AMS block (#3356)."""
+        unit = {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}
+        self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"ams": {"ams": [unit]}}})
+        no_ams = {"print": {"command": "push_status", "ams": {"ams": [], "ams_exist_bits": "0"}}}
+        self._deliver(mqtt_client, mqtt_client.topic_subscribe, no_ams)
+
+        assert mqtt_client.get_logs()[1].payload == no_ams
+
+    def test_a_later_message_does_not_change_an_earlier_entry(self, mqtt_client):
+        first = {"print": {"command": "push_status", "vt_tray": {"id": "254", "tray_type": "PLA"}}}
+        self._deliver(mqtt_client, mqtt_client.topic_subscribe, first)
+        self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"ams": {"ams": [{"id": "0", "tray": []}]}}})
+        self._deliver(mqtt_client, mqtt_client.topic_publish, {"print": {"command": "gcode_line", "param": "M400"}})
+
+        logs = mqtt_client.get_logs()
+        assert logs[0].payload == first
+        assert logs[2].payload == {"print": {"command": "gcode_line", "param": "M400"}}
+
     def test_the_report_topic_is_still_logged_as_incoming(self, mqtt_client):
         """Telemetry keeps its direction -- the two must stay distinguishable."""
         self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"gcode_state": "IDLE"}})

+ 78 - 0
backend/tests/unit/test_use_ams_no_ams_3356.py

@@ -0,0 +1,78 @@
+"""A printer with no AMS gets use_ams=False (#3356).
+
+A P1S with no AMS and nothing set on its spool holder: the matcher had nothing
+to match, so the print went out with no mapping and use_ams=True, which the
+firmware refuses with 07FF_8012 "Failed to get AMS mapping table". The
+mapping-based reconcile (#2595) never ran without a mapping. The printer's own
+push says it has no AMS (``"ams": []``, from the reporter's support package),
+so the command builder sends use_ams=False, as the slicer does.
+"""
+
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+@pytest.fixture
+def client():
+    client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="01P00A000000001", access_code="12345678")
+    client.model = "P1S"
+    client._client = MagicMock()
+    client.state.connected = True
+    return client
+
+
+def _use_ams(client) -> bool:
+    assert client._client.publish.called, "start_print did not publish"
+    return json.loads(client._client.publish.call_args.args[1])["print"]["use_ams"]
+
+
+def _no_ams(client):
+    client._handle_ams_data({"ams": []})
+
+
+def test_no_ams_and_no_mapping(client):
+    _no_ams(client)
+
+    assert client.start_print("case_body.3mf", ams_mapping=None, use_ams=True)
+
+    assert _use_ams(client) is False
+
+
+@pytest.mark.parametrize("mapping", [[254], [-1], [-1, 254]])
+def test_no_ams_with_a_mapping(client, mapping):
+    _no_ams(client)
+
+    assert client.start_print("case_body.3mf", ams_mapping=mapping, use_ams=True)
+
+    assert _use_ams(client) is False
+
+
+def test_not_heard_from_the_ams_yet(client):
+    """No AMS push handled yet (just reconnected): the AMS may be there."""
+    assert "ams" not in client.state.raw_data
+
+    assert client.start_print("case_body.3mf", ams_mapping=None, use_ams=True)
+
+    assert _use_ams(client) is True
+
+
+def test_with_an_ams(client):
+    client._handle_ams_data({"ams": [{"id": "0", "tray": [{"id": "0"}, {"id": "1"}, {"id": "2"}, {"id": "3"}]}]})
+
+    assert client.start_print("case_body.3mf", ams_mapping=None, use_ams=True)
+
+    assert _use_ams(client) is True
+
+
+def test_dual_nozzle_is_left_alone(client):
+    """On an H2D use_ams is nozzle routing, not an AMS on/off flag."""
+    client.model = "H2D"
+    _no_ams(client)
+
+    assert client.start_print("case_body.3mf", ams_mapping=None, use_ams=True)
+
+    assert _use_ams(client) is True