test_external_spool_usage_3166.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """External-spool usage charged to an AMS spool (#3166).
  2. The firmware rejects 254/255 in a print command's flat ``ams_mapping``, so
  3. BambuStudio and our own dispatch both write an external spool there as -1 and
  4. put the real target in ``ams_mapping2``. The MQTT client captured only the flat
  5. list, so a print fed from the external spool reached the usage tracker as
  6. ``[-1]`` -- and the tracker's position-based fallback then charged it to the
  7. first loaded AMS tray. The reporter's H2D logged this for every external-spool
  8. print: 225 g of ABS from the right external spool deducted from the PLA spool
  9. in AMS slot 1.
  10. The command payloads below are verbatim from that reporter's support bundle
  11. (H2D, firmware 01.03.00.00, one AMS 2 Pro).
  12. """
  13. from datetime import datetime, timezone
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.models.archive import PrintArchive
  18. from backend.app.models.spool import Spool
  19. from backend.app.services.bambu_mqtt import BambuMQTTClient, resolve_external_spools_in_mapping
  20. from backend.app.services.usage_tracker import _track_from_3mf
  21. pytestmark = pytest.mark.unit
  22. def project_file(ams_mapping, ams_mapping2=None) -> dict:
  23. print_data = {
  24. "sequence_id": "20000",
  25. "command": "project_file",
  26. "param": "Metadata/plate_1.gcode",
  27. "url": "ftp://Small_Spool_Adapter_(PLA)_A_Side.3mf",
  28. "ams_mapping": ams_mapping,
  29. }
  30. if ams_mapping2 is not None:
  31. print_data["ams_mapping2"] = ams_mapping2
  32. return {"print": print_data}
  33. EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
  34. EXT_LEFT = {"ams_id": 254, "slot_id": 0}
  35. UNMAPPED = {"ams_id": 255, "slot_id": 255}
  36. class TestResolvingTheExternalSpool:
  37. def test_dual_nozzle_right_external(self):
  38. assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=True) == [255]
  39. def test_dual_nozzle_left_external(self):
  40. assert resolve_external_spools_in_mapping([-1], [EXT_LEFT], is_dual_nozzle=True) == [254]
  41. def test_single_nozzle_external_is_254_whatever_the_wire_says(self):
  42. # BambuStudio always sends ams_id 255 for the one external spool of a
  43. # single-nozzle printer; Bambuddy knows that spool as global tray 254.
  44. assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=False) == [254]
  45. def test_mixed_mapping_only_the_external_entry_changes(self):
  46. mapping2 = [
  47. {"ams_id": 0, "slot_id": 0},
  48. EXT_RIGHT,
  49. {"ams_id": 0, "slot_id": 2},
  50. {"ams_id": 0, "slot_id": 3},
  51. ]
  52. assert resolve_external_spools_in_mapping([0, -1, 2, 3], mapping2, is_dual_nozzle=True) == [0, 255, 2, 3]
  53. def test_an_unmapped_slot_stays_unmapped(self):
  54. assert resolve_external_spools_in_mapping([0, -1], [{"ams_id": 0, "slot_id": 0}, UNMAPPED], True) == [0, -1]
  55. @pytest.mark.parametrize("mapping2", [None, "junk", [EXT_RIGHT, EXT_RIGHT]])
  56. def test_without_a_usable_mapping2_the_capture_is_untouched(self, mapping2):
  57. assert resolve_external_spools_in_mapping([-1], mapping2, is_dual_nozzle=True) == [-1]
  58. def test_non_list_mapping_is_returned_as_is(self):
  59. assert resolve_external_spools_in_mapping(None, [EXT_RIGHT], is_dual_nozzle=True) is None
  60. class TestTheCapturedMapping:
  61. def test_h2d_command_captures_the_right_external_spool(self):
  62. client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D")
  63. client._handle_request_message(project_file([-1], [EXT_RIGHT]))
  64. assert client._captured_ams_mapping == [255]
  65. def test_h2d_mixed_command(self):
  66. client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D")
  67. mapping2 = [
  68. {"ams_id": 0, "slot_id": 0},
  69. EXT_RIGHT,
  70. {"ams_id": 0, "slot_id": 2},
  71. {"ams_id": 0, "slot_id": 3},
  72. ]
  73. client._handle_request_message(project_file([0, -1, 2, 3], mapping2))
  74. assert client._captured_ams_mapping == [0, 255, 2, 3]
  75. def test_p2s_command_captures_tray_254(self):
  76. client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="P2S3166", access_code="12345678", model="P2S")
  77. client._handle_request_message(project_file([-1], [EXT_RIGHT]))
  78. assert client._captured_ams_mapping == [254]
  79. def test_command_without_mapping2_is_captured_as_before(self):
  80. client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="X1C3166", access_code="12345678", model="X1C")
  81. client._handle_request_message(project_file([-1]))
  82. assert client._captured_ams_mapping == [-1]
  83. # --- The usage tracker ---------------------------------------------------------
  84. def _spool(spool_id: int):
  85. spool = MagicMock()
  86. spool.id = spool_id
  87. spool.label_weight = 1000
  88. spool.weight_used = 0
  89. spool.cost_per_kg = None
  90. spool.material = "ABS"
  91. spool.rgba = None
  92. return spool
  93. def _db(spools: dict[int, MagicMock]):
  94. """Answer the tracker's archive and spool lookups by what they select."""
  95. archive = MagicMock()
  96. archive.id = 142
  97. archive.file_path = "archives/142/test.3mf"
  98. archive.extra_data = None
  99. archive.plate_id = None
  100. selected: list = []
  101. async def execute(stmt, *args, **kwargs):
  102. entity = stmt.column_descriptions[0].get("entity")
  103. result = MagicMock()
  104. value = None
  105. if entity is PrintArchive:
  106. value = archive
  107. elif entity is Spool:
  108. spool_id = stmt.whereclause.right.value
  109. value = spools.get(spool_id)
  110. selected.append(spool_id)
  111. result.scalar_one_or_none.return_value = value
  112. result.scalars.return_value.first.return_value = None
  113. result.scalar.return_value = None
  114. return result
  115. db = AsyncMock()
  116. db.execute = execute
  117. db.add = MagicMock()
  118. return db
  119. # The reporter's printer: PLA in AMS slot 1 (spool 20), ABS on the right
  120. # external spool (spool 4). The AMS slot is loaded, so the old position-based
  121. # fallback had a tray to land on.
  122. ASSIGNED = {(0, 0): 20, (255, 1): 4}
  123. RAW_DATA = {
  124. "ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}],
  125. "vt_tray": [{"id": "255", "tray_type": "ABS"}],
  126. }
  127. async def _track(ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255):
  128. charged: list[tuple[int, int]] = []
  129. async def resolve(printer_id, ams_id, tray_id, **kwargs):
  130. charged.append((ams_id, tray_id))
  131. return ASSIGNED.get((ams_id, tray_id))
  132. printer_manager = MagicMock()
  133. printer_manager.get_status.return_value = SimpleNamespace(
  134. progress=100,
  135. layer_num=50,
  136. tray_now=tray_now,
  137. last_loaded_tray=-1,
  138. tray_change_log=[],
  139. raw_data=RAW_DATA,
  140. )
  141. with (
  142. patch("backend.app.core.config.settings") as mock_settings,
  143. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  144. patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
  145. ):
  146. mock_path = MagicMock()
  147. mock_path.exists.return_value = True
  148. mock_settings.base_dir = MagicMock()
  149. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  150. results = await _track_from_3mf(
  151. printer_id=1,
  152. archive_id=142,
  153. status="completed",
  154. print_name="Daft_Punk_Helmet_Back",
  155. handled_trays=set(),
  156. printer_manager=printer_manager,
  157. db=_db({20: _spool(20), 4: _spool(4)}),
  158. ams_mapping=ams_mapping,
  159. tray_now_at_start=tray_now_at_start,
  160. print_started_at=datetime.now(timezone.utc),
  161. )
  162. return results, charged
  163. ABS_ONLY = [{"slot_id": 1, "used_g": 225.14, "type": "ABS", "color": "#000000"}]
  164. class TestTheTrackerCharges:
  165. @pytest.mark.asyncio
  166. async def test_the_resolved_mapping_charges_the_external_spool(self):
  167. results, charged = await _track([255], ABS_ONLY)
  168. assert [r["spool_id"] for r in results] == [4]
  169. assert charged == [(255, 1)]
  170. @pytest.mark.asyncio
  171. async def test_an_unmapped_slot_does_not_fall_back_to_an_ams_tray(self):
  172. # A two-filament print where the mapping leaves slot 2 unfed. The old
  173. # fallback handed slot 2 the second loaded tray instead.
  174. usage = [
  175. {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"},
  176. {"slot_id": 2, "used_g": 5.0, "type": "ABS", "color": "#000000"},
  177. ]
  178. results, charged = await _track([0, -1], usage)
  179. assert [r["spool_id"] for r in results] == [20]
  180. assert charged == [(0, 0)]
  181. @pytest.mark.asyncio
  182. async def test_an_all_unmapped_capture_defers_to_tray_now(self):
  183. # A command without ams_mapping2 still captures [-1]. That names no
  184. # tray, so the printer's own tray_now decides -- not AMS slot 1.
  185. results, charged = await _track([-1], ABS_ONLY, tray_now_at_start=255)
  186. assert (0, 0) not in charged
  187. assert [r["spool_id"] for r in results] == [4]