Przeglądaj źródła

fix(a2l): AMS Lite trays in captured and slicer mappings keep their global id

A print command names an AMS Lite tray by its local slot (0-3) in the
flat ams_mapping and as {ams_id: 16, slot_id: n} in ams_mapping2.
Bambuddy kept the local slot when it captured the mapping, so usage
maziggy 5 dni temu
rodzic
commit
be0ad787b4

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ 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
+- **A2L: filament used from the AMS Lite is charged to the right spool** — The printer names an AMS Lite tray by its local slot 1-4, and Bambuddy kept that number when it recorded which trays a print used, so the AMS Lite spool was never charged. With a regular AMS beside the AMS Lite, the usage went to that AMS's slot of the same number instead. A virtual printer with **Save AMS mapping** on stored the same local number for queued prints and their archives; with a regular AMS present, such a print was sent to that AMS instead of the AMS Lite.
 - **Proxy virtual printers no longer restart when another virtual printer is saved** — Creating, editing or deleting any virtual printer restarted every running proxy-mode virtual printer, dropping the slicer's connection and any upload in progress. They now restart only when their own settings change. Changing a printer's IP now restarts the proxy virtual printers aimed at it on the new address; before, they kept forwarding to the old one until a virtual printer was saved. Renaming a virtual printer now restarts it, in every mode, so the slicer sees the new name; before, a running virtual printer kept advertising its old name until Bambuddy restarted.
 - **Prints queued through a virtual printer keep the slicer's nozzle choices** —
   - **Nozzle offset calibration** from the slicer, or the default from Settings → Workflow, is used. It always ran on "Auto".

+ 12 - 5
backend/app/services/bambu_mqtt.py

@@ -209,9 +209,13 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
     global tray the rest of Bambuddy uses: the ams_id itself on dual-nozzle
     printers (254 = left/deputy, 255 = right/main), 254 on single-nozzle
     printers, which have one external spool that the wire always calls 255.
-    ``{255, 255}`` is the unmapped marker and stays -1. Every other entry is
-    left exactly as captured. Without a usable ``ams_mapping2`` the input is
-    returned unchanged.
+    ``{255, 255}`` is the unmapped marker and stays -1.
+
+    An A2L AMS-Lite tray is the local slot 0-3 in the flat list and
+    ``{ams_id: 16, slot_id: n}`` in ``ams_mapping2``; it becomes its normalised
+    global tray 24+n. Kept local, it read as a regular AMS tray, so usage was
+    charged to the wrong spool. Every other entry is left exactly as captured.
+    Without a usable ``ams_mapping2`` the input is returned unchanged.
     """
     if not isinstance(ams_mapping, list) or not isinstance(ams_mapping2, list):
         return ams_mapping
@@ -219,11 +223,14 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
         return ams_mapping
     resolved = list(ams_mapping)
     for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)):
-        if flat != -1 or not isinstance(detail, dict):
+        if not isinstance(detail, dict):
             continue
         ams_id = detail.get("ams_id")
         slot_id = detail.get("slot_id")
-        if ams_id not in (254, 255) or slot_id == 255:
+        if ams_id == A2L_LITE_PHYSICAL_AMS_ID and slot_id in (0, 1, 2, 3):
+            resolved[i] = A2L_LITE_GLOBAL_BASE + slot_id
+            continue
+        if flat != -1 or ams_id not in (254, 255) or slot_id == 255:
             continue
         resolved[i] = ams_id if is_dual_nozzle else 254
     return resolved

+ 106 - 0
backend/tests/unit/test_a2l_captured_mapping.py

@@ -0,0 +1,106 @@
+"""An A2L AMS-Lite tray in a captured print command keeps its global id.
+
+The firmware addresses the Lite as physical unit 16: a print command carries
+the LOCAL slot 0-3 in the flat ``ams_mapping`` and ``{ams_id: 16, slot_id: n}``
+in ``ams_mapping2`` (confirmed against the firmware's own mapping; our dispatch
+sends the same). Inside Bambuddy the Lite is unit 6, global trays 24-27.
+
+The captured mapping kept the local slot, so a print from Lite slot 2 (global
+26) reached usage tracking as tray 2 -- AMS 0 slot 3, or nothing on a Lite-only
+A2L -- and the Lite spool was never charged. The virtual printer took the
+slicer's mapping through the same resolver, so a queued A2L job dispatched to
+AMS 0 instead of the Lite.
+"""
+
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient, resolve_external_spools_in_mapping
+from backend.app.services.virtual_printer.manager import _extract_slicer_ams_mapping_json
+
+pytestmark = pytest.mark.unit
+
+
+def lite(slot: int) -> dict:
+    return {"ams_id": 16, "slot_id": slot}
+
+
+EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
+UNMAPPED = {"ams_id": 255, "slot_id": 255}
+
+
+def a2l_client() -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.9", serial_number="A2L0001", access_code="12345678", model="N9")
+    client._client = MagicMock()
+    client.state.connected = True
+    return client
+
+
+class TestTheResolver:
+    def test_lite_slot_becomes_its_global_tray(self):
+        assert resolve_external_spools_in_mapping([1], [lite(1)], is_dual_nozzle=False) == [25]
+
+    def test_every_lite_slot(self):
+        mapping2 = [lite(0), lite(1), lite(2), lite(3)]
+        assert resolve_external_spools_in_mapping([0, 1, 2, 3], mapping2, is_dual_nozzle=False) == [24, 25, 26, 27]
+
+    def test_regular_ams_beside_the_lite_is_untouched(self):
+        mapping2 = [{"ams_id": 0, "slot_id": 1}, lite(1), EXT_RIGHT, UNMAPPED]
+        assert resolve_external_spools_in_mapping([1, 1, -1, -1], mapping2, is_dual_nozzle=False) == [1, 25, 254, -1]
+
+    def test_only_a_minus_one_becomes_an_external_spool(self):
+        """The Lite branch moved the external-spool guard; a real flat tray
+        must still win over an ams_mapping2 entry that names the external spool."""
+        assert resolve_external_spools_in_mapping([3], [EXT_RIGHT], is_dual_nozzle=False) == [3]
+
+    def test_slots_outside_the_lite_stay_as_captured(self):
+        assert resolve_external_spools_in_mapping([1, -1], [lite(4), lite(255)], is_dual_nozzle=False) == [1, -1]
+
+    def test_without_mapping2_the_flat_list_stays(self):
+        # Nothing names the unit, so there is nothing to resolve from.
+        assert resolve_external_spools_in_mapping([1], None, is_dual_nozzle=False) == [1]
+
+
+class TestTheCapture:
+    def test_slicer_command_captures_the_global_tray(self):
+        client = a2l_client()
+        client._handle_request_message(
+            {"print": {"command": "project_file", "url": "ftp://x.3mf", "ams_mapping": [2], "ams_mapping2": [lite(2)]}}
+        )
+        assert client._captured_ams_mapping == [26]
+
+    def test_our_own_dispatch_round_trips(self):
+        """What we send for global 25 must be captured back as 25."""
+        client = a2l_client()
+        assert client.start_print("lite.3mf", ams_mapping=[25], use_ams=True) is True
+        sent = json.loads(client._client.publish.call_args.args[1])
+        assert sent["print"]["ams_mapping"] == [1]
+        assert sent["print"]["ams_mapping2"] == [lite(1)]
+
+        client._handle_request_message(sent)
+
+        assert client._captured_ams_mapping == [25]
+
+    def test_report_topic_echo_captures_the_global_tray(self):
+        """The printer's own project_file response goes through the same capture."""
+        client = a2l_client()
+        client._process_message(
+            {
+                "print": {
+                    "command": "project_file",
+                    "result": "SUCCESS",
+                    "url": "file:///userdata/model/history/lite.gcode.3mf",
+                    "ams_mapping": [0],
+                    "ams_mapping2": [lite(0)],
+                }
+            }
+        )
+        assert client._captured_ams_mapping == [24]
+
+
+class TestTheVirtualPrinterIntake:
+    def test_slicer_lite_pick_is_stored_as_the_global_tray(self):
+        data = {"ams_mapping": [3], "ams_mapping2": [lite(3)]}
+        assert json.loads(_extract_slicer_ams_mapping_json(data, "[VP test]")) == [27]