| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- """Smart-plug power-on for class-targeted queue items (#2786).
- Powering a printer on for a queued job has existed since smart plugs did, but
- only on the branch that handles an item pinned to one printer. An item queued
- as "Any X1C" carries no ``printer_id``, takes the model-based branch, and that
- branch's matcher drops an offline printer into a "Offline:" waiting reason
- without ever looking at its plugs. With every matching printer switched off the
- job sat pending indefinitely.
- The reporter's log is the controlled experiment: the same item, same plug, same
- Auto On setting, powered a printer on the moment they edited it onto a specific
- printer -- and did nothing for the thirteen minutes before that.
- """
- from contextlib import ExitStack
- from datetime import datetime, timedelta, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- from backend.app.core.database import Base
- from backend.app.models.library import LibraryFile
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- from backend.app.models.smart_plug import SmartPlug
- from backend.app.services.print_scheduler import PrintScheduler
- @pytest.fixture
- async def queue_db():
- """Two X1Cs, each on its own plug, so "which one" is a real question."""
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- session_maker = async_sessionmaker(engine, expire_on_commit=False)
- async with session_maker() as db:
- db.add_all(
- [
- Printer(
- id=1,
- name="X1C-1",
- serial_number="X1C0001",
- ip_address="10.0.0.1",
- access_code="x",
- model="X1C",
- is_active=True,
- ),
- Printer(
- id=2,
- name="X1C-2",
- serial_number="X1C0002",
- ip_address="10.0.0.2",
- access_code="x",
- model="X1C",
- is_active=True,
- ),
- ]
- )
- await db.commit()
- try:
- yield SimpleNamespace(session_maker=session_maker)
- finally:
- await engine.dispose()
- async def _add_plug(ctx, printer_id, *, auto_on=True, enabled=True, name=None):
- async with ctx.session_maker() as db:
- plug = SmartPlug(
- name=name or f"Plug {printer_id}",
- plug_type="tasmota",
- ip_address=f"10.0.1.{printer_id}",
- printer_id=printer_id,
- enabled=enabled,
- auto_on=auto_on,
- )
- db.add(plug)
- await db.commit()
- return plug.id
- async def _add_item(
- ctx, *, printer_id=None, target_model=None, sliced_for="X1C", position=1, scheduled_time=None, manual_start=False
- ):
- async with ctx.session_maker() as db:
- lib = LibraryFile(
- filename="job.gcode.3mf",
- file_path="/library/job.gcode.3mf",
- file_size=10,
- file_type="gcode.3mf",
- file_metadata={"sliced_for_model": sliced_for},
- )
- db.add(lib)
- await db.flush()
- item = PrintQueueItem(
- status="pending",
- position=position,
- printer_id=printer_id,
- target_model=target_model,
- library_file_id=lib.id,
- scheduled_time=scheduled_time,
- manual_start=manual_start,
- )
- db.add(item)
- await db.commit()
- return item.id
- async def _run(
- ctx,
- scheduler,
- *,
- power_on=AsyncMock,
- connected=False,
- awaiting_plate_clear=(),
- require_plate_clear=True,
- launched=None,
- ):
- """Run one queue pass with every printer offline unless told otherwise.
- ``power_on`` is the patched ``_power_on_and_wait``; the tests assert on the
- printer ids it was called with, which is the whole behaviour under test.
- """
- power_on_mock = power_on() if isinstance(power_on, type) else power_on
- with ExitStack() as stack:
- for p in [
- patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
- patch("backend.app.core.database.async_session", ctx.session_maker),
- patch(
- "backend.app.services.print_scheduler.printer_manager.is_connected",
- MagicMock(side_effect=lambda pid: pid in connected if connected else False),
- ),
- patch(
- "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
- MagicMock(side_effect=lambda pid: pid in awaiting_plate_clear),
- ),
- patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
- patch(
- "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
- AsyncMock(return_value={}),
- ),
- patch(
- "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
- AsyncMock(),
- ),
- patch(
- "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
- AsyncMock(),
- ),
- patch.object(scheduler, "_check_auto_drying", AsyncMock()),
- patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
- patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
- patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
- patch.object(scheduler, "_power_on_and_wait", power_on_mock),
- patch.object(
- scheduler,
- "_get_bool_setting",
- AsyncMock(
- side_effect=lambda db, key, default=False: (
- require_plate_clear if key == "require_plate_clear" else default
- )
- ),
- ),
- ]:
- stack.enter_context(p)
- await scheduler.check_queue()
- return power_on_mock
- async def _get_item(ctx, item_id):
- async with ctx.session_maker() as db:
- return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
- def _woken_printer_ids(power_on_mock):
- """Printer ids ``_power_on_and_wait(plug, printer_id, db)`` was called for."""
- return [call.args[1] for call in power_on_mock.await_args_list]
- class TestClassTargetWakesAPrinter:
- @pytest.mark.asyncio
- async def test_offline_printers_are_powered_on_for_an_any_model_job(self, queue_db):
- """The bug: this used to do nothing at all."""
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- item_id = await _add_item(queue_db, target_model="X1C")
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- assert _woken_printer_ids(power_on) == [1]
- # Assignment is left to the next pass, once the printer has reported.
- item = await _get_item(queue_db, item_id)
- assert item.status == "pending"
- assert item.printer_id is None
- @pytest.mark.asyncio
- async def test_a_printer_awaiting_plate_clear_is_passed_over(self, queue_db):
- """Waking it buys nothing -- the plate-clear gate would hold it anyway.
- This is what the reporter's log shows after a fixed-printer wake: the
- printer booted and then reported ``awaiting_plate_clear=True`` every 30
- seconds for the next 80 minutes. The flag is Bambuddy-side and
- persisted, so it is readable while the printer is still switched off.
- """
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- await _add_item(queue_db, target_model="X1C")
- power_on = await _run(
- queue_db,
- PrintScheduler(),
- power_on=AsyncMock(return_value=True),
- awaiting_plate_clear=(1,),
- )
- assert _woken_printer_ids(power_on) == [2]
- @pytest.mark.asyncio
- async def test_nothing_is_woken_when_every_candidate_awaits_plate_clear(self, queue_db):
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- await _add_item(queue_db, target_model="X1C")
- power_on = await _run(
- queue_db,
- PrintScheduler(),
- power_on=AsyncMock(return_value=True),
- awaiting_plate_clear=(1, 2),
- )
- power_on.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_plate_clear_gate_off_wakes_anyway(self, queue_db):
- """With the gate disabled the flag is not a reason to skip a printer."""
- await _add_plug(queue_db, 1)
- await _add_item(queue_db, target_model="X1C")
- power_on = await _run(
- queue_db,
- PrintScheduler(),
- power_on=AsyncMock(return_value=True),
- awaiting_plate_clear=(1,),
- require_plate_clear=False,
- )
- assert _woken_printer_ids(power_on) == [1]
- @pytest.mark.asyncio
- async def test_at_most_one_printer_is_woken_per_pass(self, queue_db):
- """Each wake blocks the queue loop for the boot wait.
- Ten class-targeted jobs must not switch on ten printers inside one
- check; the next pass wakes the next one.
- """
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- await _add_item(queue_db, target_model="X1C", position=1)
- await _add_item(queue_db, target_model="X1C", position=2)
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- assert _woken_printer_ids(power_on) == [1]
- @pytest.mark.asyncio
- async def test_a_failed_power_on_is_not_retried_in_the_same_pass(self, queue_db):
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- item_a = await _add_item(queue_db, target_model="X1C", position=1)
- item_b = await _add_item(queue_db, target_model="X1C", position=2)
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=False))
- assert _woken_printer_ids(power_on) == [1]
- # A printer we failed to switch on is off, not busy. Calling it busy
- # would misdescribe it here and, because an all-busy reason is treated
- # as needing no user action, silence the notification as well.
- for item_id in (item_a, item_b):
- reason = (await _get_item(queue_db, item_id)).waiting_reason or ""
- assert "Busy" not in reason
- assert "Offline" in reason
- @pytest.mark.asyncio
- async def test_a_dead_plug_does_not_starve_its_siblings(self, queue_db):
- """One unreachable plug must not hold every sibling of its model hostage.
- Candidates are walked in id order and a pass spends only one power-on
- attempt, so without a cool-off the broken printer is picked again on
- every pass and the healthy one behind it is never reached. It also
- costs a full boot timeout out of each 30s pass, which delays the whole
- queue rather than just this job.
- """
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- await _add_item(queue_db, target_model="X1C")
- scheduler = PrintScheduler()
- power_on = AsyncMock(return_value=False)
- await _run(queue_db, scheduler, power_on=power_on)
- await _run(queue_db, scheduler, power_on=power_on)
- assert _woken_printer_ids(power_on) == [1, 2]
- @pytest.mark.asyncio
- async def test_a_printer_is_tried_again_once_its_cooloff_expires(self, queue_db):
- """The skip is a cool-off, not a blacklist — a fixed plug is picked up."""
- await _add_plug(queue_db, 1)
- await _add_item(queue_db, target_model="X1C")
- scheduler = PrintScheduler()
- scheduler._wake_failure_cooloff = 0
- power_on = AsyncMock(return_value=False)
- await _run(queue_db, scheduler, power_on=power_on)
- await _run(queue_db, scheduler, power_on=power_on)
- assert _woken_printer_ids(power_on) == [1, 1]
- @pytest.mark.asyncio
- async def test_an_expired_cooloff_is_not_left_behind(self, queue_db):
- """The map holds one key per currently-failing printer, not per printer
- this process has ever failed to wake."""
- await _add_plug(queue_db, 1)
- await _add_item(queue_db, target_model="X1C")
- scheduler = PrintScheduler()
- scheduler._wake_failure_cooloff = 0
- await _run(queue_db, scheduler, power_on=AsyncMock(return_value=False))
- assert 1 in scheduler._wake_failures
- await _run(queue_db, scheduler, power_on=AsyncMock(return_value=True))
- assert scheduler._wake_failures == {}
- class TestWhatIsNotWokenUp:
- @pytest.mark.asyncio
- async def test_a_printer_with_no_auto_on_plug_is_left_alone_and_said_so(self, queue_db):
- """The first question asked of the reporter was whether Auto On was on.
- "Offline" and "offline with no Auto On plug" are different problems and
- only the second is one the user has to go and fix, so they must not
- share a waiting reason.
- """
- await _add_plug(queue_db, 1, auto_on=False)
- await _add_plug(queue_db, 2, enabled=False)
- item_id = await _add_item(queue_db, target_model="X1C")
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- power_on.assert_not_awaited()
- item = await _get_item(queue_db, item_id)
- assert item.waiting_reason is not None
- assert "no Auto On smart plug" in item.waiting_reason
- assert "X1C-1" in item.waiting_reason and "X1C-2" in item.waiting_reason
- @pytest.mark.asyncio
- async def test_an_incompatible_file_never_wakes_anything(self, queue_db):
- """The cross-model gate (#2578) runs before the wake, not after it.
- Switching a printer on for a file that can never legally run on it is
- worse than leaving it off: the job still cannot start, and now the
- printer is drawing power.
- """
- await _add_plug(queue_db, 1)
- await _add_item(queue_db, target_model="X1C", sliced_for="A1")
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- power_on.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_a_job_scheduled_for_later_does_not_switch_anything_on_now(self, queue_db):
- """Otherwise a print set for 3am powers a printer up the moment it is queued."""
- await _add_plug(queue_db, 1)
- await _add_item(
- queue_db,
- target_model="X1C",
- scheduled_time=datetime.now(timezone.utc) + timedelta(hours=6),
- )
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- power_on.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_a_manual_start_job_does_not_switch_anything_on(self, queue_db):
- """Manual start means the user presses play; nothing happens until they do."""
- await _add_plug(queue_db, 1)
- await _add_item(queue_db, target_model="X1C", manual_start=True)
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- power_on.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_an_already_connected_printer_is_not_powered_on(self, queue_db):
- await _add_plug(queue_db, 1)
- await _add_plug(queue_db, 2)
- await _add_item(queue_db, target_model="X1C")
- power_on = await _run(
- queue_db,
- PrintScheduler(),
- power_on=AsyncMock(return_value=True),
- connected=(1, 2),
- )
- power_on.assert_not_awaited()
- class TestFixedPrinterBranchStillWakes:
- @pytest.mark.asyncio
- async def test_an_item_pinned_to_a_printer_still_powers_it_on(self, queue_db):
- """The branch that always worked, pinned so a refactor cannot drop it."""
- await _add_plug(queue_db, 2)
- await _add_item(queue_db, printer_id=2)
- power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
- assert _woken_printer_ids(power_on) == [2]
|