| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- """Tray-state slot mapping for printers that can answer no other way (#2953).
- #2768 gave the Spoolman writer two ways to recover a print's slot-to-tray
- mapping when print start captured none: the printer's ``mapping`` field, and a
- colour match of the 3MF's slots against the loaded trays. An A1 can satisfy
- neither. It publishes no ``mapping`` field, and it drops the MQTT connection
- when Bambuddy subscribes to its request topic, so the slicer's own instruction
- never arrives either. That leaves the colour match, which compares hex strings
- exactly.
- The reporter sliced with a generic black profile (``#000000``) against a tray
- they had set to ``#111111``. No match, so every print fell through to the
- positional default and charged slot 1 to the first loaded tray -- the grey PLA+
- in tray 0 -- while the print was actually fed from tray 3. Their log carries
- the printer's own answer, ``Tray change during print: tray=3 at layer=0``,
- recorded 90 seconds into the print and already read by ``_print_used_tray_keys``
- further down the same completion pass.
- Values throughout are the ones from archive 12 of the reporter's support
- bundle.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services.spoolman_tracking import (
- _resolve_slot_to_tray_fallback,
- _single_slot_tray_from_state,
- )
- # The reporter's AMS at completion: tray 1 is empty, and no tray is #000000.
- REPORTER_AMS = [
- {
- "id": 0,
- "tray": [
- {"id": 0, "tray_color": "888888FF", "tray_type": "PLA+"},
- {"id": 1, "tray_color": None, "tray_type": None},
- {"id": 2, "tray_color": "5F4036FF", "tray_type": "PLA"},
- {"id": 3, "tray_color": "111111FF", "tray_type": "PLA+"},
- ],
- }
- ]
- REPORTER_USAGE = [{"slot_id": 1, "used_g": 2.17, "type": "PLA+", "color": "#000000"}]
- class _AsyncCtx:
- def __init__(self, db):
- self._db = db
- async def __aenter__(self):
- return self._db
- async def __aexit__(self, *exc):
- return False
- def _state(tray_change_log=None, tray_now=255, last_loaded_tray=-1, **raw):
- """An A1 as it looks at completion: no mapping field, nothing loaded."""
- return SimpleNamespace(
- raw_data=raw,
- layer_num=0,
- total_layers=0,
- tray_change_log=list(tray_change_log or []),
- tray_now=tray_now,
- last_loaded_tray=last_loaded_tray,
- )
- def _patched_pm(state):
- pm = MagicMock()
- pm.get_status.return_value = state
- return pm
- class TestSingleSlotTrayFromState:
- def test_the_mid_print_tray_change_is_the_answer(self):
- """``Tray change during print: tray=3 at layer=0``. The printer
- announced the switch while the job was running, so it describes this
- print and no other."""
- state = _state(tray_change_log=[(3, 0)])
- assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) == (1, 3)
- def test_declines_a_multi_slot_print(self):
- """Every colour change moves ``tray_now``, so one tray reading can't be
- attributed to one slot. Same gate the internal writer uses."""
- usage = [
- {"slot_id": 1, "used_g": 10.0, "color": "#000000"},
- {"slot_id": 2, "used_g": 5.0, "color": "#FFFFFF"},
- ]
- assert _single_slot_tray_from_state(_state(tray_change_log=[(3, 0)]), usage, None) is None
- def test_declines_when_the_print_switched_trays(self):
- """Two entries means AMS backup swapped a spool mid-print (#957).
- ``report_usage`` splits those per segment; handing it a single-tray
- mapping instead would charge the whole print to one of them."""
- state = _state(tray_change_log=[(3, 0), (0, 120)])
- assert _single_slot_tray_from_state(state, REPORTER_USAGE, None) is None
- def test_slots_that_consumed_nothing_do_not_count_as_a_second_slot(self):
- """A purge-only slot is in the 3MF with zero grams. It is not a second
- filament and must not disqualify the print."""
- usage = [
- {"slot_id": 1, "used_g": 2.17, "color": "#000000"},
- {"slot_id": 2, "used_g": 0.0, "color": "#FFFFFF"},
- ]
- assert _single_slot_tray_from_state(_state(tray_change_log=[(3, 0)]), usage, None) == (1, 3)
- def test_falls_back_to_tray_now_at_start(self):
- state = _state(tray_change_log=[])
- assert _single_slot_tray_from_state(state, REPORTER_USAGE, 2) == (1, 2)
- def test_falls_back_to_current_tray_now(self):
- state = _state(tray_change_log=[], tray_now=2)
- assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) == (1, 2)
- def test_falls_back_to_last_loaded_tray(self):
- """The reporter's A1 parks ``tray_now`` at 255 the moment the print
- ends, and their ``tray_now_at_start`` was 255 too because print start
- fires before the filament is loaded. ``last_loaded_tray`` only ever
- latches real trays, so it is the one still holding the answer."""
- state = _state(tray_change_log=[], tray_now=255, last_loaded_tray=3)
- assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) == (1, 3)
- def test_255_is_not_a_tray(self):
- """255 is ``tray_now`` at rest and what an unparseable reading falls
- back to. Reading it as a slot would charge a spool on no evidence."""
- state = _state(tray_change_log=[], tray_now=255, last_loaded_tray=255)
- assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) is None
- def test_no_state_at_all(self):
- assert _single_slot_tray_from_state(None, REPORTER_USAGE, None) is None
- class TestResolveSlotToTrayFallbackRung:
- def test_the_reporters_print_resolves_to_tray_3(self):
- """End of the chain: no mapping field, colours don't match, and the
- tray-change log settles it."""
- pm = _patched_pm(_state(tray_change_log=[(3, 0)], ams=REPORTER_AMS))
- with patch("backend.app.services.printer_manager.printer_manager", pm):
- mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, 255)
- assert mapping == [3]
- assert source == "tray_state"
- def test_colour_match_still_wins(self):
- """When the slicer colour does equal a tray's, that is a direct
- statement about this slot and outranks a tray reading."""
- usage = [{"slot_id": 1, "used_g": 2.17, "color": "#5F4036"}]
- pm = _patched_pm(_state(tray_change_log=[(3, 0)], ams=REPORTER_AMS))
- with patch("backend.app.services.printer_manager.printer_manager", pm):
- mapping, source = _resolve_slot_to_tray_fallback(1, usage, 255)
- assert mapping == [2]
- assert source == "color_match"
- def test_mapping_field_still_wins(self):
- pm = _patched_pm(_state(tray_change_log=[(3, 0)], mapping=[0], ams=REPORTER_AMS))
- with patch("backend.app.services.printer_manager.printer_manager", pm):
- mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, 255)
- assert mapping == [0]
- assert source == "mqtt"
- def test_an_empty_status_payload_still_reaches_the_tray_rung(self):
- """``tray_change_log`` lives on the state object, not in the status
- payload, so an empty payload must not short-circuit past it."""
- pm = _patched_pm(_state(tray_change_log=[(3, 0)]))
- with patch("backend.app.services.printer_manager.printer_manager", pm):
- mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, None)
- assert mapping == [3]
- assert source == "tray_state"
- def test_only_the_used_slot_is_claimed(self):
- """A print whose one filament is slot 3 pads the array so the index
- lines up. The padding is -1, which no caller resolves: those slots
- consumed nothing."""
- usage = [
- {"slot_id": 1, "used_g": 0.0, "color": "#AAAAAA"},
- {"slot_id": 3, "used_g": 2.17, "color": "#000000"},
- ]
- pm = _patched_pm(_state(tray_change_log=[(3, 0)]))
- with patch("backend.app.services.printer_manager.printer_manager", pm):
- mapping, source = _resolve_slot_to_tray_fallback(1, usage, None)
- assert mapping == [-1, -1, 3]
- assert source == "tray_state"
- def test_still_says_none_when_the_printer_offers_nothing(self):
- pm = _patched_pm(_state(tray_change_log=[], tray_now=255, last_loaded_tray=-1, ams=REPORTER_AMS))
- with patch("backend.app.services.printer_manager.printer_manager", pm):
- mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, 255)
- assert mapping is None
- assert source == "none"
- class TestReportUsageChargesTheRightSpool:
- """The reporter's archive 12, end to end."""
- @staticmethod
- def _run(tracking, state, spool_by_tag, archive):
- rows = iter([tracking])
- def _next_row(*_args, **_kwargs):
- result = MagicMock()
- result.scalar_one_or_none.return_value = next(rows, archive)
- return result
- db = AsyncMock()
- db.execute = AsyncMock(side_effect=_next_row)
- db.delete = AsyncMock()
- db.commit = AsyncMock()
- client = AsyncMock()
- client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spool_by_tag.get(tag))
- client.use_spool = AsyncMock()
- pm = _patched_pm(state)
- async def _go():
- from backend.app.services.spoolman_tracking import report_usage
- with (
- patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
- patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
- patch(
- "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
- AsyncMock(return_value=client),
- ),
- patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
- patch(
- "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
- AsyncMock(return_value=None),
- ),
- patch("backend.app.services.printer_manager.printer_manager", pm),
- ):
- await report_usage(printer_id=1, archive_id=12)
- return _go, client
- @staticmethod
- def _tracking():
- return SimpleNamespace(
- filament_usage=list(REPORTER_USAGE),
- ams_trays={
- "0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA+"},
- "2": {"tray_uuid": "TRAY2", "tag_uid": "", "tray_type": "PLA"},
- "3": {"tray_uuid": "TRAY3", "tag_uid": "", "tray_type": "PLA+"},
- },
- slot_to_tray=None,
- tray_remain_start=None,
- layer_usage=None,
- filament_properties=None,
- tray_now_at_start=255,
- )
- SPOOLS = {
- # Spool 41 is the grey PLA+ that was wrongly charged 2.17 g.
- "TRAY0": {"id": 41, "filament": {"color_hex": "888888", "material": "PLA+"}},
- "TRAY2": {"id": 20, "filament": {"color_hex": "5F4036", "material": "PLA"}},
- "TRAY3": {"id": 46, "filament": {"color_hex": "111111", "material": "PLA+"}},
- }
- @pytest.mark.asyncio
- async def test_the_tray_the_printer_named_is_charged(self):
- archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
- state = _state(tray_change_log=[(3, 0)], tray_now=255, last_loaded_tray=3, ams=REPORTER_AMS)
- run, client = self._run(self._tracking(), state, self.SPOOLS, archive)
- await run()
- client.use_spool.assert_awaited_once_with(46, 2.17)
- @pytest.mark.asyncio
- async def test_the_archive_is_not_restamped_from_a_positional_guess(self):
- """Nothing named a tray, so slot 1 is charged by position. The grams
- can be put back; overwriting what the slicer recorded cannot, so the
- archive keeps the colour and material it was printed with."""
- archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
- state = _state(tray_change_log=[], tray_now=255, last_loaded_tray=-1, ams=REPORTER_AMS)
- run, client = self._run(self._tracking(), state, self.SPOOLS, archive)
- await run()
- client.use_spool.assert_awaited_once_with(41, 2.17)
- assert archive.filament_color == "#000000"
- assert archive.filament_type == "PLA+"
- @pytest.mark.asyncio
- async def test_a_resolved_mapping_still_restamps_the_archive(self):
- """#1494 and #2563 are unchanged when the mapping is actually known."""
- archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
- state = _state(tray_change_log=[(3, 0)], tray_now=255, last_loaded_tray=3, ams=REPORTER_AMS)
- run, _client = self._run(self._tracking(), state, self.SPOOLS, archive)
- await run()
- assert archive.filament_color == "#111111"
- class TestTraySplitIsNotAPositionalGuess:
- """A print that switched trays mid-run is attributed per segment from the
- tray-change log (#1793). That path never reads ``slot_to_tray``, so the
- absence of a mapping says nothing about it -- treating it as a positional
- guess would suppress the archive rewrite for the prints whose attribution
- is best supported, and log a warning naming a mechanism that did not run.
- """
- @pytest.mark.asyncio
- async def test_a_runout_switch_without_a_mapping_still_restamps_the_archive(self):
- from backend.app.services.spoolman_tracking import report_usage
- tracking = SimpleNamespace(
- filament_usage=[{"slot_id": 1, "used_g": 72.56, "type": "PLA+", "color": "#000000"}],
- ams_trays={
- "0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA+"},
- "3": {"tray_uuid": "TRAY3", "tag_uid": "", "tray_type": "PLA+"},
- },
- # The #2768 condition: a Studio print, so print start stored nothing.
- slot_to_tray=None,
- tray_remain_start=None,
- layer_usage={},
- filament_properties={},
- tray_now_at_start=255,
- )
- # The #1793 condition: AMS backup switched tray 0 -> tray 3 at layer 50.
- state = SimpleNamespace(
- raw_data={"ams": REPORTER_AMS},
- tray_change_log=[(0, 0), (3, 50)],
- total_layers=100,
- layer_num=100,
- tray_now=255,
- last_loaded_tray=3,
- )
- spools = {
- "TRAY0": {"id": 41, "filament": {"color_hex": "888888", "material": "PLA+"}},
- "TRAY3": {"id": 46, "filament": {"color_hex": "111111", "material": "PLA+"}},
- }
- archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
- rows = iter([tracking])
- def _next_row(*_args, **_kwargs):
- result = MagicMock()
- result.scalar_one_or_none.return_value = next(rows, archive)
- return result
- db = AsyncMock()
- db.execute = AsyncMock(side_effect=_next_row)
- db.delete = AsyncMock()
- db.commit = AsyncMock()
- client = AsyncMock()
- client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spools.get(tag))
- client.use_spool = AsyncMock()
- with (
- patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
- patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
- patch(
- "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
- AsyncMock(return_value=client),
- ),
- patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
- patch(
- "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
- AsyncMock(return_value=None),
- ),
- patch("backend.app.services.printer_manager.printer_manager", _patched_pm(state)),
- ):
- await report_usage(printer_id=1, archive_id=12)
- # Both segments charged, so the split path is what ran.
- charged = {c.args[0] for c in client.use_spool.await_args_list}
- assert charged == {41, 46}
- # And the archive was rewritten from the spools the segments named,
- # rather than being left alone as a guess would be.
- assert archive.filament_color != "#000000"
|