| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- """External-spool usage charged to an AMS spool (#3166).
- The firmware rejects 254/255 in a print command's flat ``ams_mapping``, so
- BambuStudio and our own dispatch both write an external spool there as -1 and
- put the real target in ``ams_mapping2``. The MQTT client captured only the flat
- list, so a print fed from the external spool reached the usage tracker as
- ``[-1]`` -- and the tracker's position-based fallback then charged it to the
- first loaded AMS tray. The reporter's H2D logged this for every external-spool
- print: 225 g of ABS from the right external spool deducted from the PLA spool
- in AMS slot 1.
- The command payloads below are verbatim from that reporter's support bundle
- (H2D, firmware 01.03.00.00, one AMS 2 Pro).
- """
- from datetime import datetime, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.models.archive import PrintArchive
- from backend.app.models.spool import Spool
- from backend.app.services.bambu_mqtt import BambuMQTTClient, resolve_external_spools_in_mapping
- from backend.app.services.usage_tracker import _track_from_3mf
- pytestmark = pytest.mark.unit
- def project_file(ams_mapping, ams_mapping2=None) -> dict:
- print_data = {
- "sequence_id": "20000",
- "command": "project_file",
- "param": "Metadata/plate_1.gcode",
- "url": "ftp://Small_Spool_Adapter_(PLA)_A_Side.3mf",
- "ams_mapping": ams_mapping,
- }
- if ams_mapping2 is not None:
- print_data["ams_mapping2"] = ams_mapping2
- return {"print": print_data}
- EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
- EXT_LEFT = {"ams_id": 254, "slot_id": 0}
- UNMAPPED = {"ams_id": 255, "slot_id": 255}
- class TestResolvingTheExternalSpool:
- def test_dual_nozzle_right_external(self):
- assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=True) == [255]
- def test_dual_nozzle_left_external(self):
- assert resolve_external_spools_in_mapping([-1], [EXT_LEFT], is_dual_nozzle=True) == [254]
- def test_single_nozzle_external_is_254_whatever_the_wire_says(self):
- # BambuStudio always sends ams_id 255 for the one external spool of a
- # single-nozzle printer; Bambuddy knows that spool as global tray 254.
- assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=False) == [254]
- def test_mixed_mapping_only_the_external_entry_changes(self):
- mapping2 = [
- {"ams_id": 0, "slot_id": 0},
- EXT_RIGHT,
- {"ams_id": 0, "slot_id": 2},
- {"ams_id": 0, "slot_id": 3},
- ]
- assert resolve_external_spools_in_mapping([0, -1, 2, 3], mapping2, is_dual_nozzle=True) == [0, 255, 2, 3]
- def test_an_unmapped_slot_stays_unmapped(self):
- assert resolve_external_spools_in_mapping([0, -1], [{"ams_id": 0, "slot_id": 0}, UNMAPPED], True) == [0, -1]
- @pytest.mark.parametrize("mapping2", [None, "junk", [EXT_RIGHT, EXT_RIGHT]])
- def test_without_a_usable_mapping2_the_capture_is_untouched(self, mapping2):
- assert resolve_external_spools_in_mapping([-1], mapping2, is_dual_nozzle=True) == [-1]
- def test_non_list_mapping_is_returned_as_is(self):
- assert resolve_external_spools_in_mapping(None, [EXT_RIGHT], is_dual_nozzle=True) is None
- class TestTheCapturedMapping:
- def test_h2d_command_captures_the_right_external_spool(self):
- client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D")
- client._handle_request_message(project_file([-1], [EXT_RIGHT]))
- assert client._captured_ams_mapping == [255]
- def test_h2d_mixed_command(self):
- client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D")
- mapping2 = [
- {"ams_id": 0, "slot_id": 0},
- EXT_RIGHT,
- {"ams_id": 0, "slot_id": 2},
- {"ams_id": 0, "slot_id": 3},
- ]
- client._handle_request_message(project_file([0, -1, 2, 3], mapping2))
- assert client._captured_ams_mapping == [0, 255, 2, 3]
- def test_p2s_command_captures_tray_254(self):
- client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="P2S3166", access_code="12345678", model="P2S")
- client._handle_request_message(project_file([-1], [EXT_RIGHT]))
- assert client._captured_ams_mapping == [254]
- def test_command_without_mapping2_is_captured_as_before(self):
- client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="X1C3166", access_code="12345678", model="X1C")
- client._handle_request_message(project_file([-1]))
- assert client._captured_ams_mapping == [-1]
- # --- The usage tracker ---------------------------------------------------------
- def _spool(spool_id: int):
- spool = MagicMock()
- spool.id = spool_id
- spool.label_weight = 1000
- spool.weight_used = 0
- spool.cost_per_kg = None
- spool.material = "ABS"
- spool.rgba = None
- return spool
- def _db(spools: dict[int, MagicMock]):
- """Answer the tracker's archive and spool lookups by what they select."""
- archive = MagicMock()
- archive.id = 142
- archive.file_path = "archives/142/test.3mf"
- archive.extra_data = None
- archive.plate_id = None
- selected: list = []
- async def execute(stmt, *args, **kwargs):
- entity = stmt.column_descriptions[0].get("entity")
- result = MagicMock()
- value = None
- if entity is PrintArchive:
- value = archive
- elif entity is Spool:
- spool_id = stmt.whereclause.right.value
- value = spools.get(spool_id)
- selected.append(spool_id)
- result.scalar_one_or_none.return_value = value
- result.scalars.return_value.first.return_value = None
- result.scalar.return_value = None
- return result
- db = AsyncMock()
- db.execute = execute
- db.add = MagicMock()
- return db
- # The reporter's printer: PLA in AMS slot 1 (spool 20), ABS on the right
- # external spool (spool 4). The AMS slot is loaded, so the old position-based
- # fallback had a tray to land on.
- ASSIGNED = {(0, 0): 20, (255, 1): 4}
- RAW_DATA = {
- "ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}],
- "vt_tray": [{"id": "255", "tray_type": "ABS"}],
- }
- async def _track(ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255):
- charged: list[tuple[int, int]] = []
- async def resolve(printer_id, ams_id, tray_id, **kwargs):
- charged.append((ams_id, tray_id))
- return ASSIGNED.get((ams_id, tray_id))
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- progress=100,
- layer_num=50,
- tray_now=tray_now,
- last_loaded_tray=-1,
- tray_change_log=[],
- raw_data=RAW_DATA,
- )
- with (
- patch("backend.app.core.config.settings") as mock_settings,
- patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
- patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
- ):
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir = MagicMock()
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- results = await _track_from_3mf(
- printer_id=1,
- archive_id=142,
- status="completed",
- print_name="Daft_Punk_Helmet_Back",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=_db({20: _spool(20), 4: _spool(4)}),
- ams_mapping=ams_mapping,
- tray_now_at_start=tray_now_at_start,
- print_started_at=datetime.now(timezone.utc),
- )
- return results, charged
- ABS_ONLY = [{"slot_id": 1, "used_g": 225.14, "type": "ABS", "color": "#000000"}]
- class TestTheTrackerCharges:
- @pytest.mark.asyncio
- async def test_the_resolved_mapping_charges_the_external_spool(self):
- results, charged = await _track([255], ABS_ONLY)
- assert [r["spool_id"] for r in results] == [4]
- assert charged == [(255, 1)]
- @pytest.mark.asyncio
- async def test_an_unmapped_slot_does_not_fall_back_to_an_ams_tray(self):
- # A two-filament print where the mapping leaves slot 2 unfed. The old
- # fallback handed slot 2 the second loaded tray instead.
- usage = [
- {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"},
- {"slot_id": 2, "used_g": 5.0, "type": "ABS", "color": "#000000"},
- ]
- results, charged = await _track([0, -1], usage)
- assert [r["spool_id"] for r in results] == [20]
- assert charged == [(0, 0)]
- @pytest.mark.asyncio
- async def test_an_all_unmapped_capture_defers_to_tray_now(self):
- # A command without ams_mapping2 still captures [-1]. That names no
- # tray, so the printer's own tray_now decides -- not AMS slot 1.
- results, charged = await _track([-1], ABS_ONLY, tray_now_at_start=255)
- assert (0, 0) not in charged
- assert [r["spool_id"] for r in results] == [4]
|