mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Print commands carry the external spool as -1 in the flat ams_mapping (the firmware rejects 254/255 there) and the real target only in ams_mapping2. We captured only the flat list, so external-spool prints looked unmapped and the usage tracker's position-based fallback charged them to the first loaded AMS tray. - Resolve external spools from ams_mapping2 when capturing a project_file (dual-nozzle keeps 254/255, single-nozzle -> 254) - Tracker: an explicit -1 no longer falls back to a positional tray; a mapping naming no tray for any used slot defers to tray_now - Keep the #1822 H2S tray_now override working with resolved mappings
231 lines
9.0 KiB
Python
231 lines
9.0 KiB
Python
"""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]
|