소스 검색

fix(notifications,drying): printer-limited channels get only their printer's events; blocking mode pre-dries for scheduled jobs; model jobs say "(drying)"; remove dead load-tray state

maziggy 4 일 전
부모
커밋
e05a917445

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A notification channel limited to one printer gets that printer's events only** — A job added for **Any <model>** or not yet assigned, and any job waiting (for filament, a plate, a printer), were sent without a printer, so no printer filter applied: every channel limited to a printer received all of them, other printers' included. A job that waits on a printer now names it, and reaches that printer's channels; one with no printer yet goes to channels set to all printers, and a channel limited to one printer hears of it once it is assigned to or started there. A power alert from a plug linked to no printer likewise goes to channels set to all printers. Events about the whole setup (the queue finishing, stock, app messages) still reach every channel.
+- **Queue drying pre-dries in blocking mode** — With **Wait for drying to complete** on, a new drying cycle was refused on any printer with a pending job, scheduled ones included, so queue drying never dried anything ahead of a scheduled print. Only a job that could start now keeps a new cycle off; a printer whose job is scheduled for later is dried ahead of it, and the job still waits if the cycle has not finished by then.
+- **An "Any model" job waiting for a drying printer says so** — It read "Busy: X1C-01", as if that printer were printing; it now reads "Busy: X1C-01 (drying)", as a job for that printer does.
 - **An H2D shows the right active slot after a filament change** — An H2D reports its active slot as a bare number and names the AMS in each nozzle's own data, often in the same message. That message's AMS part was read first, against the previous message's nozzle data, so after a swap the card (and usage tracking) could keep pointing at the old slot until the printer reported its slot again. The nozzle data is now read first, and nozzle data from an earlier message no longer overrules the slot the printer just reported.
 - **Home Assistant "Run when printer turns off" scripts run when Bambuddy switches the printer off** — Linked scripts ran only when someone switched the printer's plug by hand. Auto-off after a print or drying (also when resumed after a restart), the plug's schedule and AI failure detection turned the printer off without them, and the schedule turned it on without its "on" scripts. They now run on all of these. "On" scripts keep running at every print start as before. For these automatic switches only a plug that powers the printer counts, the scripts run once when two plugs feed the printer, and a script whose **Enabled** toggle is off does not run (by hand either). Failure detection no longer tries to switch off a script.
 - **A plate check that could not run says so** — When the camera gave no image, the analysis failed or there was no calibration to compare against, the check before a print reported an empty plate, logged "Plate is empty" and told nobody. The print still goes ahead, but users who can control printers now get a warning with the reason, the log records it, and **Check Plate** shows it instead of "Plate appears empty".

+ 0 - 6
backend/app/services/bambu_mqtt.py

@@ -1545,10 +1545,6 @@ class BambuMQTTClient:
         # home_flag bit 10 is read from every status frame (#3259).
         self._backup_home_flag_trusted: bool = False
 
-        # Track last requested tray ID for H2D dual-nozzle printers
-        # H2D only reports slot number (0-3) in tray_now, not global tray ID
-        # We use our tracked value to resolve the correct global ID
-        self._last_load_tray_id: int | None = None
         # Whether the frame being handled carried the nozzles' snow: only then
         # may snow overrule the slot the same frame reports as tray_now.
         self._snow_in_frame = False
@@ -7839,7 +7835,6 @@ class BambuMQTTClient:
 
         # Track this load request for H2D dual-nozzle disambiguation
         # H2D reports only slot number (0-3) in tray_now, so we use our tracked value
-        self._last_load_tray_id = tray_id
         self.state.pending_tray_target = tray_id
         logger.info("[%s] Set pending_tray_target=%s for H2D disambiguation", self.serial_number, tray_id)
 
@@ -7946,7 +7941,6 @@ class BambuMQTTClient:
         logger.info("[%s] Unloading filament (tray_now was %s)", self.serial_number, tray_now)
 
         # Clear tracked load request since we're unloading
-        self._last_load_tray_id = None
         self.state.pending_tray_target = None
         logger.info("[%s] Cleared pending_tray_target (unload)", self.serial_number)
 

+ 22 - 7
backend/app/services/notification_service.py

@@ -1574,9 +1574,17 @@ class NotificationService:
         db: AsyncSession,
         event_field: str,
         printer_id: int | None = None,
+        *,
+        fleet_wide: bool = False,
     ) -> list[NotificationProvider]:
         """Get all enabled providers that want a specific event type.
 
+        A provider limited to one printer gets that printer's events. An event
+        with no printer -- a job for "Any <model>" not yet assigned -- reaches
+        only "All printers" providers, unless it is about the whole fleet
+        (``fleet_wide``: the queue finishing, stock, app messages), which
+        every provider that wants it gets.
+
         Runs under ``no_autoflush`` (#2770). Callers routinely hold pending
         writes when they raise an event — the AMS sensor loop does
         ``db.add(history)`` and only commits after the alarms have gone out — and
@@ -1598,6 +1606,8 @@ class NotificationService:
             query = query.where(
                 (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
             )
+        elif not fleet_wide:
+            query = query.where(NotificationProvider.printer_id.is_(None))
 
         with db.no_autoflush:
             result = await db.execute(query)
@@ -1727,7 +1737,7 @@ class NotificationService:
         carries it. Returns how many channels it was handed to. ``allowed``
         narrows the channels to those the sender may reach.
         """
-        providers = await self._get_providers_for_event(db, "on_app_message")
+        providers = await self._get_providers_for_event(db, "on_app_message", fleet_wide=True)
         if allowed is not None:
             providers = [provider for provider in providers if allowed(provider)]
         if not providers:
@@ -2561,7 +2571,7 @@ class NotificationService:
         # a single printer via provider.printer_id, and a location alert has no
         # printer to scope by, so sharing it would leak drybox alerts to a
         # provider narrowed to one printer's sensors.
-        providers = await self._get_providers_for_event(db, "on_location_ha_sensor_alert", None)
+        providers = await self._get_providers_for_event(db, "on_location_ha_sensor_alert", fleet_wide=True)
         if not providers:
             return
 
@@ -2824,9 +2834,14 @@ class NotificationService:
         target_model: str,
         waiting_reason: str,
         db: AsyncSession,
+        printer_id: int | None = None,
     ):
-        """Handle job waiting for filament event."""
-        providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None)
+        """Handle job waiting for filament event.
+
+        ``printer_id`` is the printer a pinned job waits on; None for a job for
+        "Any <model>" that no printer holds.
+        """
+        providers = await self._get_providers_for_event(db, "on_queue_job_waiting", printer_id)
         if not providers:
             return
 
@@ -2893,7 +2908,7 @@ class NotificationService:
         db: AsyncSession,
     ):
         """Handle all queue jobs completed event."""
-        providers = await self._get_providers_for_event(db, "on_queue_completed", None)
+        providers = await self._get_providers_for_event(db, "on_queue_completed", fleet_wide=True)
         if not providers:
             return
 
@@ -2928,7 +2943,7 @@ class NotificationService:
         leaves out the providers that have the break alert on, so the producer can send
         the reorder event for a break SKU without telling those providers twice.
         """
-        providers = await self._get_providers_for_event(db, "on_stock_reorder_alert", None)
+        providers = await self._get_providers_for_event(db, "on_stock_reorder_alert", fleet_wide=True)
         if skip_break_subscribers:
             providers = [p for p in providers if not p.on_stock_break_alert]
         if not providers:
@@ -2964,7 +2979,7 @@ class NotificationService:
 
         ``subtype`` and ``color`` as for :meth:`on_stock_reorder_alert`.
         """
-        providers = await self._get_providers_for_event(db, "on_stock_break_alert", None)
+        providers = await self._get_providers_for_event(db, "on_stock_break_alert", fleet_wide=True)
         if not providers:
             return
 

+ 33 - 10
backend/app/services/print_scheduler.py

@@ -1733,6 +1733,7 @@ class PrintScheduler:
                         target_model=(entry[1] if entry else "") or "",
                         waiting_reason=reason,
                         db=db,
+                        printer_id=item.printer_id,
                     )
                 except Exception as e:
                     # A queue that cannot say why it is waiting is the bug being
@@ -2296,6 +2297,7 @@ class PrintScheduler:
                                 wakeable_ids=wakeable_printer_ids,
                                 printer_scope=item_scope,
                                 is_short=is_short,
+                                drying_ids=drying_held,
                             )
                             # "Only if the previous print succeeded" is about the
                             # printer the job lands on: one whose last print failed
@@ -3192,6 +3194,7 @@ class PrintScheduler:
         wakeable_ids: set[int] | None = None,
         printer_scope: PrinterScope = ALL_PRINTERS,
         is_short: Callable[[int], Awaitable[bool]] | None = None,
+        drying_ids: set[int] | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -3214,6 +3217,8 @@ class PrintScheduler:
                       is not. When every eligible printer is short, the first is
                       returned all the same, so the deficit gate holds the job there
                       and "Print Anyway" still has a printer to print on.
+            drying_ids: Excluded printers held by a drying cycle; named "(drying)"
+                        in the reason, as a pinned job's wait is.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
@@ -3252,7 +3257,7 @@ class PrintScheduler:
                     if missing_colors:
                         printers_missing_filament.append((printer.name, missing_colors))
                         continue
-                printers_busy.append(printer.name)
+                printers_busy.append(f"{printer.name} (drying)" if printer.id in (drying_ids or ()) else printer.name)
                 continue
 
             is_connected = printer_manager.is_connected(printer.id)
@@ -5229,7 +5234,7 @@ class PrintScheduler:
             # reason enough to run the check. Its record is forgotten when the
             # relay is switched off, not when the broker drops: a reconnect must
             # not re-send every low slot.
-            notify = bool(await notification_service._get_providers_for_event(db, "on_filament_low"))
+            notify = bool(await notification_service._get_providers_for_event(db, "on_filament_low", fleet_wide=True))
             relay = mqtt_relay.enabled and mqtt_relay.connected
             if not notify:
                 self._notified_filament_low.clear()
@@ -5448,8 +5453,12 @@ class PrintScheduler:
             if self._stock_alerts_persisted is None:
                 await self._load_stock_alerts(db)
             wanted = {
-                "reorder": bool(await notification_service._get_providers_for_event(db, "on_stock_reorder_alert")),
-                "break": bool(await notification_service._get_providers_for_event(db, "on_stock_break_alert")),
+                "reorder": bool(
+                    await notification_service._get_providers_for_event(db, "on_stock_reorder_alert", fleet_wide=True)
+                ),
+                "break": bool(
+                    await notification_service._get_providers_for_event(db, "on_stock_break_alert", fleet_wide=True)
+                ),
             }
             if not any(wanted.values()):
                 # Nobody wants either event. Forget what was sent, so whoever
@@ -5779,7 +5788,11 @@ class PrintScheduler:
         # Find printers with scheduled items (for queue drying mode)
         # An "Any <model>" job counts for every printer it could land on.
         printers_with_scheduled: set[int] = set()
-        printers_with_items: set[int] = set()
+        # Printers with a job that could start now: the ones block mode keeps
+        # a new cycle off. A job waiting for its start time is what queue
+        # drying dries ahead of.
+        printers_with_ready_items: set[int] = set()
+        now_utc = datetime.now(timezone.utc)
         creator_scopes: dict[int | None, PrinterScope] = {}
         model_printers: dict[tuple[str, str | None, int | None], set[int]] = {}
         for item in queue_items:
@@ -5793,9 +5806,13 @@ class PrintScheduler:
                 )
             else:
                 continue
-            printers_with_items |= item_printers
             if item.scheduled_time and not item.manual_start:
                 printers_with_scheduled |= item_printers
+            sched = item.scheduled_time
+            if sched is not None and sched.tzinfo is None:
+                sched = sched.replace(tzinfo=timezone.utc)
+            if sched is None or sched <= now_utc:
+                printers_with_ready_items |= item_printers
 
         # If only queue mode is on and no printers have scheduled items, stop drying
         # (but skip this short-circuit when print_drying_enabled is on — busy printers
@@ -5861,10 +5878,13 @@ class PrintScheduler:
                         await self._stop_drying(pid)
                     logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
                     continue
-                # When block mode is on, don't START new drying on printers with pending items.
-                # But allow already-drying printers through so humidity auto-stop logic still runs.
-                if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
-                    logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+                # When block mode is on, don't START new drying on printers with
+                # a job that could start now: the cycle would hold it. One
+                # scheduled for later may pre-dry; if the cycle is still running
+                # at its time, the hold applies then. Already-drying printers go
+                # through so humidity auto-stop logic still runs.
+                if block_for_drying and pid in printers_with_ready_items and not self._drying_in_progress.get(pid):
+                    logger.debug("Auto-drying: printer %d skipped — has a job ready to start (block mode)", pid)
                     continue
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
@@ -8100,6 +8120,7 @@ class PrintScheduler:
                     target_model=(printer.model if printer else "") or "",
                     waiting_reason="filament_short",
                     db=db,
+                    printer_id=item.printer_id,
                 )
             except Exception as e:
                 logger.debug("filament_short notification failed for item %s: %s", item.id, e)
@@ -8212,6 +8233,8 @@ class PrintScheduler:
                 target_model=(printer.model if printer else "") or "",
                 waiting_reason=f"needs {wanted}",
                 db=db,
+                # Released to the pool: no printer holds it any more.
+                printer_id=None if release_assignment else held_by,
             )
         except Exception as e:
             logger.debug("filament_missing notification failed for item %s: %s", item.id, e)

+ 122 - 0
backend/tests/integration/test_notification_scope_without_printer.py

@@ -0,0 +1,122 @@
+"""An event about a job that has no printer yet reaches "All printers" channels only.
+
+A channel limited to one printer received every "job added" for an "Any
+<model>" or unassigned job, and every "job waiting": with no printer on the
+event, no printer filter applied. A wait for a job pinned to a printer names
+that printer and reaches its channels. It hears of such a
+job once it is assigned to or started on its printer. Events about the whole
+fleet -- the queue finishing, stock, app messages -- still reach every channel.
+"""
+
+import pytest
+
+from backend.app.models.notification import NotificationProvider
+from backend.app.services.notification_service import NotificationService
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _providers(db_session, printer_factory):
+    printer = await printer_factory()
+    rows = [
+        NotificationProvider(
+            name="everyone", provider_type="ntfy", config="{}", on_queue_job_added=True, on_queue_completed=True
+        ),
+        NotificationProvider(
+            name="one printer",
+            provider_type="ntfy",
+            config="{}",
+            printer_id=printer.id,
+            on_queue_job_added=True,
+            on_queue_completed=True,
+        ),
+    ]
+    db_session.add_all(rows)
+    await db_session.commit()
+    return printer.id
+
+
+async def _names(service, db_session, event, printer_id=None, **kwargs):
+    providers = await service._get_providers_for_event(db_session, event, printer_id, **kwargs)
+    return sorted(p.name for p in providers)
+
+
+async def test_a_job_without_a_printer_reaches_all_printer_channels_only(db_session, printer_factory):
+    await _providers(db_session, printer_factory)
+
+    assert await _names(NotificationService(), db_session, "on_queue_job_added") == ["everyone"]
+
+
+async def test_a_job_on_the_printer_reaches_its_channel(db_session, printer_factory):
+    printer_id = await _providers(db_session, printer_factory)
+
+    assert await _names(NotificationService(), db_session, "on_queue_job_added", printer_id) == [
+        "everyone",
+        "one printer",
+    ]
+
+
+async def test_a_fleet_event_reaches_every_channel(db_session, printer_factory):
+    await _providers(db_session, printer_factory)
+
+    assert await _names(NotificationService(), db_session, "on_queue_completed", fleet_wide=True) == [
+        "everyone",
+        "one printer",
+    ]
+
+
+async def test_the_fleet_events_are_marked_so():
+    """Their callers pass fleet_wide, or they would silently lose scoped channels."""
+    import inspect
+
+    from backend.app.services import notification_service, print_scheduler
+
+    source = inspect.getsource(notification_service.NotificationService)
+    for event in (
+        "on_app_message",
+        "on_location_ha_sensor_alert",
+        "on_queue_completed",
+        "on_stock_reorder_alert",
+        "on_stock_break_alert",
+    ):
+        line = next(text for text in source.splitlines() if f'_get_providers_for_event(db, "{event}"' in text)
+        assert "fleet_wide=True" in line, event
+    scheduler = inspect.getsource(print_scheduler)
+    for event in ("on_filament_low", "on_stock_reorder_alert", "on_stock_break_alert"):
+        line = next(text for text in scheduler.splitlines() if f'_get_providers_for_event(db, "{event}"' in text)
+        assert "fleet_wide=True" in line, event
+
+
+@pytest.mark.parametrize(("printer_bound", "reached"), [(True, ["everyone", "one printer"]), (False, ["everyone"])])
+async def test_a_job_waiting_on_its_printer_reaches_that_printers_channel(
+    db_session, printer_factory, printer_bound, reached
+):
+    from unittest.mock import AsyncMock, patch
+
+    printer = await printer_factory()
+    db_session.add_all(
+        [
+            NotificationProvider(name="everyone", provider_type="ntfy", config="{}", on_queue_job_waiting=True),
+            NotificationProvider(
+                name="one printer",
+                provider_type="ntfy",
+                config="{}",
+                printer_id=printer.id,
+                on_queue_job_waiting=True,
+            ),
+        ]
+    )
+    await db_session.commit()
+    service = NotificationService()
+    send = AsyncMock()
+
+    with patch.object(service, "_send_to_providers", send):
+        await service.on_queue_job_waiting(
+            job_name="Bracket",
+            target_model="X1C",
+            waiting_reason="filament_short",
+            db=db_session,
+            printer_id=printer.id if printer_bound else None,
+        )
+
+    assert sorted(p.name for p in send.await_args.args[0]) == reached

+ 2 - 2
backend/tests/unit/services/test_location_sensor_alert_provider_scope_2824.py

@@ -45,7 +45,7 @@ async def test_printer_scoped_provider_does_not_get_location_alerts_by_default(s
     await session.commit()
 
     service = NotificationService()
-    providers = await service._get_providers_for_event(session, "on_location_ha_sensor_alert", None)
+    providers = await service._get_providers_for_event(session, "on_location_ha_sensor_alert", fleet_wide=True)
 
     assert providers == []
 
@@ -72,7 +72,7 @@ async def test_provider_opted_into_location_alerts_receives_them(session):
     await session.commit()
 
     service = NotificationService()
-    providers = await service._get_providers_for_event(session, "on_location_ha_sensor_alert", None)
+    providers = await service._get_providers_for_event(session, "on_location_ha_sensor_alert", fleet_wide=True)
 
     assert {p.name for p in providers} == {"Global", "Printer 5, opted into location alerts too"}
 

+ 9 - 7
backend/tests/unit/test_scheduler_auto_drying.py

@@ -10,6 +10,7 @@ Covers:
 
 import re
 import time
+from datetime import datetime, timedelta, timezone
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -577,7 +578,7 @@ class TestMinimumDryingTime:
         # Queue item with schedule
         item = MagicMock()
         item.printer_id = 1
-        item.scheduled_time = MagicMock()  # Has a schedule
+        item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)  # Has a schedule
         item.manual_start = False
 
         await scheduler._check_auto_drying(db, [item], set())
@@ -630,7 +631,7 @@ class TestMinimumDryingTime:
 
         item = MagicMock()
         item.printer_id = 1
-        item.scheduled_time = MagicMock()
+        item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)
         item.manual_start = False
 
         await scheduler._check_auto_drying(db, [item], set())
@@ -1124,10 +1125,10 @@ class TestBlockForDryingBugFix(_DryingTestBase):
         }
         db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
 
-        # Queue item exists for this printer (triggers block mode gate)
+        # Queue item due on this printer (triggers block mode gate)
         item = MagicMock()
         item.printer_id = 1
-        item.scheduled_time = MagicMock()
+        item.scheduled_time = datetime.now(timezone.utc) - timedelta(minutes=1)
         item.manual_start = False
 
         await scheduler._check_auto_drying(db, [item], set())
@@ -1175,7 +1176,8 @@ class TestBlockForDryingBugFix(_DryingTestBase):
 
         item = MagicMock()
         item.printer_id = 1
-        item.scheduled_time = MagicMock()
+        # Due now: a job that could start keeps a new cycle off in block mode.
+        item.scheduled_time = datetime.now(timezone.utc) - timedelta(minutes=1)
         item.manual_start = False
 
         await scheduler._check_auto_drying(db, [item], set())
@@ -2080,7 +2082,7 @@ class TestAmbientDryingSustainedDelay(_DryingTestBase):
 
         item = MagicMock()
         item.printer_id = 1
-        item.scheduled_time = MagicMock()
+        item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)
         item.manual_start = False
 
         await scheduler._check_auto_drying(db, [item], set())
@@ -2277,7 +2279,7 @@ class TestAmbientDryingSustainedDelay(_DryingTestBase):
 
         item = MagicMock()
         item.printer_id = 1
-        item.scheduled_time = MagicMock()
+        item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)
         item.manual_start = False
 
         await scheduler._check_auto_drying(db, [item], {1})

+ 58 - 0
backend/tests/unit/test_scheduler_block_mode_predry.py

@@ -0,0 +1,58 @@
+"""With drying set to block the queue, a job scheduled for later may still have
+its printer pre-dried.
+
+The block refused a new drying cycle on any printer with a pending job, and a
+scheduled job is one, so queue-only drying -- whose purpose is drying ahead of
+scheduled prints -- never dried anything in this mode. A job waiting for its
+start time is not held up by a cycle meant to end before then; if the cycle is
+still running when it is due, the hold applies as usual.
+"""
+
+from datetime import datetime, timedelta
+
+import pytest
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests.unit.test_scheduler_model_job_drying_c5 import _considered_for_drying
+from backend.tests.unit.test_scheduler_pinned_waiting_reason_3074 import (
+    _add_item,
+    _set,
+    ctx,  # noqa: F401 - fixture
+)
+
+
+async def _schedule(ctx, item_id: int, delta: timedelta) -> None:
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+        item.scheduled_time = datetime.utcnow() + delta
+        await db.commit()
+
+
+@pytest.mark.asyncio
+async def test_a_job_scheduled_for_later_lets_its_printer_pre_dry(ctx):
+    await _set(ctx, "queue_drying_enabled", "true")
+    await _set(ctx, "queue_drying_block", "true")
+    item_id = await _add_item(ctx, printer_id=1)
+    await _schedule(ctx, item_id, timedelta(hours=2))
+
+    assert await _considered_for_drying(ctx, PrintScheduler()) == {1}
+
+
+@pytest.mark.asyncio
+async def test_a_job_that_could_start_now_still_blocks_a_new_cycle(ctx):
+    await _set(ctx, "ambient_drying_enabled", "true")
+    await _set(ctx, "queue_drying_block", "true")
+    await _add_item(ctx, printer_id=1)
+
+    assert await _considered_for_drying(ctx, PrintScheduler()) == set()
+
+
+@pytest.mark.asyncio
+async def test_a_job_whose_time_has_come_blocks_too(ctx):
+    await _set(ctx, "ambient_drying_enabled", "true")
+    await _set(ctx, "queue_drying_block", "true")
+    item_id = await _add_item(ctx, printer_id=1)
+    await _schedule(ctx, item_id, timedelta(minutes=-5))
+
+    assert await _considered_for_drying(ctx, PrintScheduler()) == set()

+ 17 - 0
backend/tests/unit/test_scheduler_filament_deficit.py

@@ -155,3 +155,20 @@ async def test_skip_filament_check_short_circuits_without_compute(scheduler, db_
     # acknowledged the deficit.
     assert item.filament_short is False
     assert item.manual_start is False
+
+
+@pytest.mark.asyncio
+async def test_the_waiting_notification_names_the_printer(scheduler, db_session, queue_item):
+    """A channel limited to the job's printer is told the job is short."""
+    item = await queue_item()
+    waiting = AsyncMock()
+    deficit = FilamentDeficit(
+        slot_id=1, ams_id=0, tray_id=0, filament_type="PLA", required_grams=270.0, remaining_grams=200.0
+    )
+    with (
+        patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(return_value=[deficit])),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_waiting", waiting),
+    ):
+        await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert waiting.await_args.kwargs["printer_id"] == item.printer_id

+ 3 - 2
backend/tests/unit/test_scheduler_model_job_drying_c5.py

@@ -76,8 +76,9 @@ class TestTheHoldCoversModelJobs:
         launched.assert_not_called()
         item = await _item(ctx, item_id)
         assert item.printer_id is None
-        # Busy-shaped: the cycle ends on its own, nothing to alert about.
-        assert item.waiting_reason == "Busy: X1C-01"
+        # Busy-shaped: the cycle ends on its own, nothing to alert about. Named
+        # like a pinned job's, so the wait reads as drying, not as printing.
+        assert item.waiting_reason == "Busy: X1C-01 (drying)"
 
     @pytest.mark.asyncio
     async def test_a_parked_timer_does_not_hold_it(self, ctx):

+ 2 - 0
backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py

@@ -342,6 +342,8 @@ class TestOnlyWhatTheUserMustActOnMakesANoise:
         waiting.assert_called_once()
         assert waiting.call_args.kwargs["waiting_reason"] == "Waiting for plate confirmation: X1C-01"
         assert waiting.call_args.kwargs["target_model"] == "X1C"
+        # Names its printer: a channel limited to X1C-01 is told too.
+        assert waiting.call_args.kwargs["printer_id"] == 1
 
     @pytest.mark.asyncio
     async def test_the_plate_notification_survives_the_print_that_came_before_it(self, ctx):