Browse Source

fix(usage-tracker): capture ams_mapping from MQTT request topic for slicer-initiated prints

Subscribe to the MQTT request topic (device/{serial}/request) to intercept
print commands from any source (BambuStudio, OrcaSlicer, Bambu Handy, or
Bambuddy itself). Captures ams_mapping from project_file commands and passes
it through print start/complete callbacks. Fixes wrong tray attribution on
H2D Pro when prints are started from external slicers.
maziggy 3 months ago
parent
commit
12399abfea

+ 1 - 0
CHANGELOG.md

@@ -48,6 +48,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Printer Queue Widget Shows "Archive #null" for File Manager Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The "Next in queue" widget on the printer card only checked `archive_name` and `archive_id` when displaying the queued item name. Queue items from the file manager have `library_file_name` and `library_file_id` instead, so the widget displayed "Archive #null". Now falls back to `library_file_name` and `library_file_id`, matching the Queue page display logic.
 - **Inventory Usage Not Tracked for Remapped AMS Slots** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When reprinting an archive with a different AMS slot mapping (e.g. changing from slot A1 to C4 in the mapping modal), the usage tracker used the default 3MF slot-to-tray mapping instead of the actual mapping from the print command. The `ams_mapping` from reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking.
 - **Inventory Usage Not Tracked for Slicer-Initiated Prints on H2D** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2D printers, the AMS `tray_now` field is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to "unloaded" when the AMS retracts filament at completion. The usage tracker now tracks `last_loaded_tray` — the last valid tray seen during printing — as a fallback when both `tray_now` at start and at completion are invalid. Also captures `tray_now` at print start for printers that report a valid value before the RUNNING state.
+- **Inventory Usage Wrong Tray for Slicer-Initiated Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was started from an external slicer (BambuStudio, OrcaSlicer, Bambu Handy), Bambuddy never saw the `ams_mapping` the slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back to `tray_now` which could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing the `ams_mapping` universally — regardless of who starts the print.
 - **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.
 
 ### Improved

+ 5 - 1
backend/app/main.py

@@ -2203,7 +2203,11 @@ async def on_print_complete(printer_id: int, data: dict):
 
     # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
     usage_results: list[dict] = []
-    stored_ams_mapping = _print_ams_mappings.pop(archive_id, None) if archive_id else None
+    # Prefer ams_mapping captured from MQTT request topic (works for all print sources)
+    stored_ams_mapping = data.get("ams_mapping")
+    # Fallback to _print_ams_mappings for queue/reprint (set before print starts)
+    if not stored_ams_mapping and archive_id:
+        stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting

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

@@ -300,6 +300,10 @@ class BambuMQTTClient:
         # We use our tracked value to resolve the correct global ID
         self._last_load_tray_id: int | None = None
 
+        # Captured ams_mapping from print commands on the request topic
+        # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
+        self._captured_ams_mapping: list[int] | None = None
+
     @property
     def topic_subscribe(self) -> str:
         return f"device/{self.serial_number}/report"
@@ -333,6 +337,7 @@ class BambuMQTTClient:
         if rc == 0:
             self.state.connected = True
             client.subscribe(self.topic_subscribe)
+            client.subscribe(self.topic_publish)  # Intercept print commands for ams_mapping
             # Request full status update (includes nozzle info in push_status response)
             self._request_push_all()
             # Request firmware version info
@@ -371,6 +376,11 @@ class BambuMQTTClient:
             self._last_message_time = time.time()
             self.state.connected = True
 
+            # Intercept request-topic messages (print commands from slicer/Bambuddy)
+            if msg.topic == self.topic_publish:
+                self._handle_request_message(payload)
+                return
+
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
@@ -389,6 +399,20 @@ class BambuMQTTClient:
         except json.JSONDecodeError:
             pass  # Ignore non-JSON MQTT messages (e.g. binary or malformed payloads)
 
+    def _handle_request_message(self, data: dict) -> None:
+        """Intercept print commands on the request topic to capture ams_mapping."""
+        print_data = data.get("print", {})
+        if not isinstance(print_data, dict):
+            return
+        command = print_data.get("command", "")
+        if command == "project_file" and "ams_mapping" in print_data:
+            self._captured_ams_mapping = print_data["ams_mapping"]
+            logger.info(
+                "[%s] Captured ams_mapping from print command: %s",
+                self.serial_number,
+                self._captured_ams_mapping,
+            )
+
     def _process_message(self, payload: dict):
         """Process incoming MQTT message from printer."""
         # Handle top-level AMS data (comes outside of "print" key)
@@ -1923,6 +1947,7 @@ class BambuMQTTClient:
                     if self.state.remaining_time > 0
                     else None,  # Convert minutes to seconds
                     "raw_data": data,
+                    "ams_mapping": self._captured_ams_mapping,
                 }
             )
 
@@ -1981,8 +2006,10 @@ class BambuMQTTClient:
                     "raw_data": data,
                     "timelapse_was_active": timelapse_was_active,
                     "hms_errors": hms_errors_data,
+                    "ams_mapping": self._captured_ams_mapping,
                 }
             )
+            self._captured_ams_mapping = None
 
         self._previous_gcode_state = self.state.state
         if current_file:

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

@@ -858,3 +858,239 @@ class TestNozzleRackData:
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
         assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
         assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
+
+
+class TestRequestTopicAmsMapping:
+    """Tests for capturing ams_mapping from the MQTT request topic."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):
+        """Verify _captured_ams_mapping starts as None."""
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_captures_ams_mapping(self, mqtt_client):
+        """project_file command with ams_mapping stores the mapping."""
+        data = {
+            "print": {
+                "command": "project_file",
+                "ams_mapping": [0, 4, -1, -1],
+                "url": "ftp://192.168.1.100/test.3mf",
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
+
+    def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):
+        """Non-project_file commands don't store ams_mapping."""
+        data = {
+            "print": {
+                "command": "pause",
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):
+        """project_file command without ams_mapping doesn't store anything."""
+        data = {
+            "print": {
+                "command": "project_file",
+                "url": "ftp://192.168.1.100/test.3mf",
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):
+        """Non-dict print value is safely ignored."""
+        data = {"print": "not_a_dict"}
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_ignores_missing_print(self, mqtt_client):
+        """Message without print key is safely ignored."""
+        data = {"pushing": {"command": "pushall"}}
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_captured_mapping_overwrites_previous(self, mqtt_client):
+        """A new print command overwrites a previously captured mapping."""
+        mqtt_client._captured_ams_mapping = [0, -1, -1, -1]
+        data = {
+            "print": {
+                "command": "project_file",
+                "ams_mapping": [4, 8, -1, -1],
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]
+
+    def test_print_start_callback_includes_ams_mapping(self, mqtt_client):
+        """on_print_start callback data includes captured ams_mapping."""
+        start_data = {}
+
+        def on_start(data):
+            start_data.update(data)
+
+        mqtt_client.on_print_start = on_start
+        mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
+
+        # Trigger print start
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert start_data.get("ams_mapping") == [0, 4, -1, -1]
+
+    def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):
+        """on_print_start callback has ams_mapping=None when no mapping captured."""
+        start_data = {}
+
+        def on_start(data):
+            start_data.update(data)
+
+        mqtt_client.on_print_start = on_start
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert "ams_mapping" in start_data
+        assert start_data["ams_mapping"] is None
+
+    def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
+        """on_print_complete callback data includes captured ams_mapping."""
+        complete_data = {}
+
+        def on_complete(data):
+            complete_data.update(data)
+
+        mqtt_client.on_print_start = lambda d: None
+        mqtt_client.on_print_complete = on_complete
+        mqtt_client._captured_ams_mapping = [0, 9, -1, -1]
+
+        # Start print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        # Complete print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert complete_data.get("ams_mapping") == [0, 9, -1, -1]
+
+    def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):
+        """_captured_ams_mapping is reset to None after print completion."""
+        mqtt_client.on_print_start = lambda d: None
+        mqtt_client.on_print_complete = lambda d: None
+        mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
+
+        # Start print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        # Complete print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_full_flow_capture_and_deliver(self, mqtt_client):
+        """Full flow: slicer sends print command → MQTT captures mapping → completion delivers it."""
+        complete_data = {}
+
+        def on_complete(data):
+            complete_data.update(data)
+
+        mqtt_client.on_print_start = lambda d: None
+        mqtt_client.on_print_complete = on_complete
+
+        # 1. Slicer sends print command (captured from request topic)
+        mqtt_client._handle_request_message(
+            {
+                "print": {
+                    "command": "project_file",
+                    "ams_mapping": [4, 9, -1, -1],
+                    "url": "ftp://192.168.1.100/model.3mf",
+                }
+            }
+        )
+        assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]
+
+        # 2. Printer reports RUNNING
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/model.gcode",
+                    "subtask_name": "Model",
+                }
+            }
+        )
+
+        # 3. Printer reports FINISH
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/model.gcode",
+                    "subtask_name": "Model",
+                }
+            }
+        )
+
+        assert complete_data["ams_mapping"] == [4, 9, -1, -1]
+        assert complete_data["status"] == "completed"
+        # Mapping cleared after completion
+        assert mqtt_client._captured_ams_mapping is None