| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- """A slot that loses its K-profile selection gets the stored one back (#3219).
- An X1 Carbon power-cycled mid-print came back with every AMS slot on
- ``cali_idx: -1`` while tags, spools and remain% were unchanged. ``on_ams_change``
- never fired, so nothing restored the selections, and two queued jobs printed
- on the default K.
- """
- from contextlib import asynccontextmanager
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services import kprofile_drift
- from backend.app.services.slot_kprofile import SlotKProfile
- PRINTER = 7
- def _tray(tray_id, cali_idx=-1, tray_type="PLA"):
- return {"id": str(tray_id), "tray_type": tray_type, "cali_idx": cali_idx, "tray_info_idx": "GFA00"}
- def _state(trays, *, state="IDLE", connected=True, vt_tray=None, model_nozzles=1):
- raw = {"ams": {"ams": [{"id": "0", "tray": trays}]}}
- if vt_tray is not None:
- raw["vt_tray"] = vt_tray
- nozzles = [SimpleNamespace(nozzle_diameter="0.4", nozzle_type="") for _ in range(model_nozzles)]
- return SimpleNamespace(
- raw_data=raw,
- connected=connected,
- state=state,
- nozzles=nozzles,
- ams_extruder_map=None,
- ams_switch_inlet=None,
- )
- def _profile(cali_idx, name="PLA Basic 0.025"):
- return SlotKProfile(cali_idx=cali_idx, k_value=0.025, name=name, extruder=0, filament_id="GFA00")
- @pytest.fixture(autouse=True)
- def _clean_bookkeeping():
- kprofile_drift._watches.clear()
- kprofile_drift._default_chosen.clear()
- kprofile_drift._running.clear()
- yield
- kprofile_drift._watches.clear()
- kprofile_drift._default_chosen.clear()
- kprofile_drift._running.clear()
- @pytest.fixture
- def env():
- """Patch the printer, the database and the stored-profile lookup."""
- client = MagicMock()
- stored: dict[tuple[int, int], SlotKProfile] = {}
- holder = SimpleNamespace(state=None, client=client, stored=stored, model="X1C")
- async def lookup(_db, _printer_id, ams_id, tray_id, *_args, **_kwargs):
- return stored.get((ams_id, tray_id))
- @asynccontextmanager
- async def session():
- yield MagicMock()
- pm = MagicMock()
- pm.get_status.side_effect = lambda _pid: holder.state
- pm.get_client.side_effect = lambda _pid: holder.client
- pm.get_model.side_effect = lambda _pid: holder.model
- with (
- patch.object(kprofile_drift, "printer_manager", pm),
- patch.object(kprofile_drift, "async_session", session),
- patch.object(kprofile_drift, "find_slot_kprofile_for_extruder", AsyncMock(side_effect=lookup)) as finder,
- ):
- holder.finder = finder
- yield holder
- def _selected(client):
- return [
- (c.kwargs["ams_id"], c.kwargs["tray_id"], c.kwargs["cali_idx"])
- for c in client.extrusion_cali_sel.call_args_list
- ]
- @pytest.mark.asyncio
- async def test_lost_selection_is_restored(env):
- env.state = _state([_tray(0), _tray(1)])
- env.stored[(0, 0)] = _profile(9354)
- env.stored[(0, 1)] = _profile(3175)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 2
- assert sorted(_selected(env.client)) == [(0, 0, 9354), (0, 1, 3175)]
- call = env.client.extrusion_cali_sel.call_args_list[0]
- assert call.kwargs["filament_id"] == "GFA00"
- assert call.kwargs["nozzle_diameter"] == "0.4"
- @pytest.mark.asyncio
- async def test_missing_cali_idx_counts_as_lost(env):
- tray = _tray(0)
- del tray["cali_idx"]
- env.state = _state([tray])
- env.stored[(0, 0)] = _profile(9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- @pytest.mark.asyncio
- async def test_a_different_real_profile_is_left_alone(env):
- # Picked on purpose, in Bambu Studio or Bambuddy's Configure Slot dialog.
- env.state = _state([_tray(0, cali_idx=5)])
- env.stored[(0, 0)] = _profile(9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- env.client.extrusion_cali_sel.assert_not_called()
- @pytest.mark.asyncio
- async def test_slot_without_stored_profile_is_left_on_default(env):
- env.state = _state([_tray(0)])
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- env.client.extrusion_cali_sel.assert_not_called()
- @pytest.mark.asyncio
- async def test_empty_slot_is_skipped(env):
- env.state = _state([_tray(0, tray_type="")])
- env.stored[(0, 0)] = _profile(9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- env.finder.assert_not_called()
- @pytest.mark.asyncio
- async def test_deliberate_default_pick_is_respected(env):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- kprofile_drift.note_slot_configured(PRINTER, 0, 0, -1)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- # Picking a real profile again in the dialog lifts it.
- kprofile_drift.note_slot_configured(PRINTER, 0, 0, 9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- @pytest.mark.asyncio
- async def test_default_pick_survives_the_push_that_still_shows_the_old_profile(env):
- env.stored[(0, 0)] = _profile(9354)
- kprofile_drift.note_slot_configured(PRINTER, 0, 0, -1)
- # The push right after the pick can still carry the previous selection.
- env.state = _state([_tray(0, cali_idx=9354)])
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- env.state = _state([_tray(0)])
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- @pytest.mark.asyncio
- async def test_emptying_the_slot_clears_a_default_pick(env):
- env.stored[(0, 0)] = _profile(9354)
- kprofile_drift.note_slot_configured(PRINTER, 0, 0, -1)
- env.state = _state([_tray(0, tray_type="")])
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- env.state = _state([_tray(0)])
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- @pytest.mark.asyncio
- async def test_retries_are_spaced_and_capped(env):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- clock = [1000.0]
- with patch.object(kprofile_drift.time, "monotonic", side_effect=lambda: clock[0]):
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- # The next push, before the printer has applied it: no resend.
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- for _ in range(kprofile_drift.MAX_ATTEMPTS + 2):
- clock[0] += kprofile_drift.RETRY_INTERVAL_S
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- assert len(_selected(env.client)) == kprofile_drift.MAX_ATTEMPTS
- @pytest.mark.asyncio
- async def test_a_selection_that_sticks_resets_the_retry_count(env):
- env.stored[(0, 0)] = _profile(9354)
- env.state = _state([_tray(0)])
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- env.state = _state([_tray(0, cali_idx=9354)])
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- # Lost again later, e.g. the next power cycle: restored at once.
- env.state = _state([_tray(0)])
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- @pytest.mark.asyncio
- async def test_disconnect_forgets_retries(env):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- kprofile_drift.forget_printer(PRINTER)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- @pytest.mark.asyncio
- async def test_dispatch_guard_ignores_the_retry_interval(env):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, throttle=False) == 1
- @pytest.mark.asyncio
- async def test_dispatch_guard_runs_while_an_idle_pass_is_in_flight(env):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- kprofile_drift._running.add(PRINTER)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, throttle=False) == 1
- @pytest.mark.asyncio
- async def test_dispatch_guard_only_touches_the_trays_the_job_uses(env):
- env.state = _state([_tray(0), _tray(1), _tray(2)])
- for tray_id in range(3):
- env.stored[(0, tray_id)] = _profile(100 + tray_id)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, {2}, throttle=False) == 1
- assert _selected(env.client) == [(0, 2, 102)]
- @pytest.mark.asyncio
- async def test_external_spool_is_covered(env):
- env.state = _state([], vt_tray=[{"id": "254", "tray_type": "PETG", "cali_idx": -1}])
- env.stored[(255, 0)] = _profile(42)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER, {254}, throttle=False) == 1
- assert _selected(env.client) == [(255, 0, 42)]
- @pytest.mark.asyncio
- async def test_dual_nozzle_with_unknown_routing_is_not_guessed(env):
- env.model = "H2D"
- env.state = _state([_tray(0)], model_nozzles=2)
- env.stored[(0, 0)] = _profile(9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- env.finder.assert_not_called()
- @pytest.mark.asyncio
- async def test_disconnected_printer_is_skipped(env):
- env.state = _state([_tray(0)], connected=False)
- env.stored[(0, 0)] = _profile(9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 0
- @pytest.mark.asyncio
- async def test_single_nozzle_external_spool_finds_a_profile_under_either_extruder(env):
- """X1C external holder: slot_extruder says 1, the spool form stores 0."""
- env.state = _state([], vt_tray=[{"id": "254", "tray_type": "PETG", "cali_idx": -1}])
- seen = []
- async def lookup(_db, _pid, ams_id, tray_id, extruder, *_a, **_k):
- seen.append(extruder)
- return _profile(42) if extruder == 1 else None
- env.finder.side_effect = lookup
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- assert seen == [0, 1]
- @pytest.mark.asyncio
- async def test_single_nozzle_ams_slot_asks_for_extruder_zero_only(env):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- assert [c.args[4] for c in env.finder.await_args_list] == [0]
- @pytest.mark.asyncio
- async def test_dual_nozzle_uses_only_the_slot_nozzle(env):
- env.model = "H2D"
- env.state = _state([_tray(0)], model_nozzles=2)
- env.state.ams_extruder_map = {"0": 1}
- env.stored[(0, 0)] = _profile(9354)
- assert await kprofile_drift.reapply_lost_kprofiles(PRINTER) == 1
- assert [c.args[4] for c in env.finder.await_args_list] == [1]
- @pytest.mark.asyncio
- async def test_slot_with_nothing_stored_is_looked_up_rarely(env):
- env.state = _state([_tray(0)])
- clock = [1000.0]
- with patch.object(kprofile_drift.time, "monotonic", side_effect=lambda: clock[0]):
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- clock[0] += kprofile_drift.RETRY_INTERVAL_S * 2
- assert kprofile_drift.needs_check(PRINTER, env.state) is False
- clock[0] += kprofile_drift.NO_PROFILE_RECHECK_S
- assert kprofile_drift.needs_check(PRINTER, env.state) is True
- assert env.finder.await_count == 1
- @pytest.mark.asyncio
- async def test_giving_up_is_logged_once_after_the_last_attempt_had_time_to_land(env, caplog):
- env.state = _state([_tray(0)])
- env.stored[(0, 0)] = _profile(9354)
- clock = [1000.0]
- with patch.object(kprofile_drift.time, "monotonic", side_effect=lambda: clock[0]):
- for _ in range(kprofile_drift.MAX_ATTEMPTS):
- await kprofile_drift.reapply_lost_kprofiles(PRINTER)
- # Not yet: the last send may still stick.
- assert "giving up" not in caplog.text
- clock[0] += kprofile_drift.RETRY_INTERVAL_S
- for _ in range(3):
- kprofile_drift.needs_check(PRINTER, env.state)
- clock[0] += kprofile_drift.RETRY_INTERVAL_S
- assert caplog.text.count("giving up") == 1
- class TestNeedsCheck:
- def test_idle_with_a_lost_slot(self):
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)])) is True
- @pytest.mark.parametrize("gcode_state", ["RUNNING", "PAUSE", "PREPARE", "SLICING", "unknown"])
- def test_never_while_a_print_may_be_running(self, gcode_state):
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)], state=gcode_state)) is False
- @pytest.mark.parametrize("gcode_state", ["IDLE", "FINISH", "FAILED"])
- def test_idle_states(self, gcode_state):
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)], state=gcode_state)) is True
- def test_not_when_every_slot_has_a_profile(self):
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0, cali_idx=3)])) is False
- def test_not_while_disconnected(self):
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)], connected=False)) is False
- def test_not_while_a_pass_is_running(self):
- kprofile_drift._running.add(PRINTER)
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)])) is False
- def test_not_again_within_the_retry_interval(self):
- kprofile_drift._watches[(PRINTER, 0, 0)] = kprofile_drift._SlotWatch(
- next_check=kprofile_drift.time.monotonic() + 30
- )
- assert kprofile_drift.needs_check(PRINTER, _state([_tray(0)])) is False
- @pytest.mark.asyncio
- async def test_status_handler_spawns_the_check_and_forgets_on_disconnect():
- """The #3219 trigger lives in on_printer_status_change: every push, idle only."""
- from backend.app import main
- spawned = []
- def fake_spawn(coro, name=None):
- spawned.append(name)
- coro.close()
- with (
- patch.object(main, "spawn_background_task", side_effect=fake_spawn),
- patch.object(main.kprofile_drift, "needs_check", return_value=True) as needs_check,
- patch.object(main.kprofile_drift, "forget_printer") as forget,
- patch.object(main, "ws_manager") as ws,
- ):
- ws.send_printer_status = AsyncMock()
- state = MagicMock()
- state.connected = True
- state.state = "IDLE"
- state.nozzles = []
- try:
- await main.on_printer_status_change(PRINTER, state)
- except Exception:
- pass # Later parts of the handler need more of a real state.
- assert f"reapply-kprofiles-{PRINTER}" in spawned
- needs_check.assert_called_once_with(PRINTER, state)
- spawned.clear()
- state.connected = False
- try:
- await main.on_printer_status_change(PRINTER, state)
- except Exception:
- pass
- assert f"reapply-kprofiles-{PRINTER}" not in spawned
- forget.assert_called_with(PRINTER)
- @pytest.mark.asyncio
- async def test_a_failing_check_does_not_stop_the_status_broadcast():
- from backend.app import main
- with (
- patch.object(main.kprofile_drift, "needs_check", side_effect=RuntimeError("boom")),
- patch.object(main, "ws_manager") as ws,
- patch.object(main, "spawn_background_task", side_effect=lambda coro, name=None: coro.close()),
- ):
- ws.send_printer_status = AsyncMock()
- state = MagicMock()
- state.connected = True
- state.state = "IDLE"
- state.nozzles = []
- try:
- await main.on_printer_status_change(PRINTER, state)
- except RuntimeError as exc:
- pytest.fail(f"K-profile check escaped the status handler: {exc}")
- except Exception:
- pass # Later parts of the handler need more of a real state.
|