From cb5f04d287184dd91ec5a730d5c3561c72ee85d7 Mon Sep 17 00:00:00 2001 From: Kouki Ojima Date: Tue, 29 Sep 2026 20:50:52 +0900 Subject: [PATCH] Give the Low Filament notification something that fires it (issue #2913) (#2940) --- backend/app/services/notification_service.py | 4 +- backend/app/services/print_scheduler.py | 282 ++++++++ .../test_filament_low_producer_2913.py | 627 ++++++++++++++++++ 3 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 backend/tests/unit/services/test_filament_low_producer_2913.py diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 61ef16269..494c0738d 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1814,7 +1814,7 @@ class NotificationService: self, printer_id: int, printer_name: str, - slot: int, + slot: str, remaining_percent: int, db: AsyncSession, color: str | None = None, @@ -1826,7 +1826,7 @@ class NotificationService: variables = { "printer": printer_name, - "slot": str(slot), + "slot": slot, "remaining_percent": str(remaining_percent), "color": color or "", } diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 0b929f818..873a317e8 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -60,6 +60,10 @@ from backend.app.services.printer_manager import ( supports_drying_while_printing, ) from backend.app.services.smart_plug_manager import smart_plug_manager +from backend.app.services.spool_assignment_notifications import ( + _global_tray_from_assignment, + _slot_label_from_global_tray, +) from backend.app.utils.ams_drying import is_countdown_parked from backend.app.utils.ams_humidity import ams_humidity_percent from backend.app.utils.archive_paths import archive_photos_dir @@ -83,6 +87,47 @@ from backend.app.utils.threemf_tools import ( logger = logging.getLogger(__name__) + +def _ams_slot_label(ams_id: int, tray_id: int) -> str: + """Human slot name for an assignment tuple, in the spelling already in use. + + Composed from the notification side's own pair rather than spelled out again + here. The hand-rolled version this replaces got three real slot kinds wrong: + ams_id 254 fell into its ``>= 128`` branch and came out as ``HT-`` plus + ``chr(191)``; both externals collapsed to one label, because an external + assignment stores ams_id 255 with tray_id picking left or right + (inventory.py:1771, ``ext_id = data.tray_id + 254``); and an A2L AMS-Lite, + normalised to unit 6, read as ``AMS-G`` instead of ``Lite-``. + + Naming the same slot two ways is its own defect once an alert and the + Inventory page are meant to be talking about the same tray, so this defers + rather than adding a fifth spelling. + """ + return _slot_label_from_global_tray(_global_tray_from_assignment(ams_id, tray_id)) + + +# Minimum seconds between low-filament checks. Matches the scheduler's idle +# interval: the fast path runs every 3 s while an upload is in flight, and a +# low spool does not need answering at that resolution (#2913). +_FILAMENT_LOW_MIN_INTERVAL = 30.0 + + +def _remaining_percent(label_weight: int | float | None, weight_used: float | None) -> float | None: + """Remaining filament as a percentage of the label weight. + + The same arithmetic the Inventory page's Low Stock count uses, and it works + unchanged in both inventory modes: internal spools store ``weight_used`` + directly, and ``_map_spoolman_spool`` derives it from Spoolman's + ``remaining_weight`` so the shape matches. Returns None when there is no + label weight to be a percentage of -- a spool that cannot say how full it + started cannot say how empty it is. + """ + if not label_weight or label_weight <= 0: + return None + remaining = max(0.0, float(label_weight) - float(weight_used or 0.0)) + return remaining / float(label_weight) * 100.0 + + # Dispatch-toast progress throttling (#1625 follow-up). Mirrors the legacy # background_dispatch.py upload_progress_callback (200 ms time gate + 256 KB # byte gate) from before the scheduler unification. Time gate keeps small @@ -998,6 +1043,23 @@ class PrintScheduler: # streak began # last -- monotonic stamp of the last pass that observed the streak self._auto_dry_above: dict[tuple[int, int], dict[str, float]] = {} + # Slots already notified as low on filament: + # {(printer_id, ams_id, tray_id, spool_id)}. + # Cleared when the slot goes back above its threshold rather than on a + # timer, mirroring _notified_hms_errors — a spool hovering at the + # boundary must not produce an alert on every pass, and a slot that has + # been refilled has to be able to alert again. See #2913. + # + # The spool id is part of the key because the slot alone cannot clear + # itself. Pull a low spool and put a different part-used one in the same + # slot: the key survives the pass where the slot resolves to nothing -- + # deliberately, so a brief Spoolman outage does not re-alert everything + # when it returns -- and the replacement never goes above the threshold, + # so it can never re-arm. Keyed with the spool, the new spool is simply a + # different key and the stale one is inert. + self._notified_filament_low: set[tuple[int, int, int, int]] = set() + # Earliest monotonic time the next low-filament check may run (#2913). + self._filament_low_next_check: float = 0.0 # Printers with a "running" scheduled drying row (#2638). Rebuilt from the # DB on every _check_scheduled_dryings call so route-side cancels show up. # Auto-drying's stop-all branches must not stop or untrack these printers; @@ -1331,6 +1393,7 @@ class PrintScheduler: self._sweep_keep_warm(active_candidates=set(), dispatched=set()) inflight_printers = {pid for (_task, pid) in self._inflight.values() if pid is not None} await self._check_auto_drying(db, [], inflight_printers) + await self._check_filament_low(db) return bool(self._inflight) logger.info( @@ -2109,6 +2172,12 @@ class PrintScheduler: # Auto-drying: start drying on idle printers that have no pending queue items await self._check_auto_drying(db, items, dispatching_printers) + # Low filament: alert on assigned spools that have crossed their + # low-stock threshold (#2913). Runs on both paths out of this method + # for the same reason auto-drying does — an empty queue does not mean + # the spools in the printers stopped mattering. + await self._check_filament_low(db) + # Keep the loop on the fast interval while any upload is in flight so # a slot freed mid-tick refills within seconds rather than after the # 30 s idle sleep (#2602). Selecting anything this pass (launched or @@ -4245,6 +4314,219 @@ class PrintScheduler: return None return (min_temp, max_hours or 12, filament_type) + async def _check_filament_low(self, db: AsyncSession) -> None: + """Alert on AMS slots whose assigned spool has crossed its low-stock threshold (#2913). + + ``on_filament_low`` has had a column, a schema field, a route, a template + and a UI toggle since the notification system was built, and no caller -- + so the toggle could be switched on and could never fire. This is the + producer. + + No new setting. ``low_stock_threshold`` (default 20%) with the per-spool + ``low_stock_threshold_pct`` override is already exactly this decision: + already configurable, already surfaced, already driving the Inventory + page's Low Stock count. The AMS ``remain`` percentage is deliberately not + consulted -- it has been measured up to 56 points out against a scale, + which is not a number to page someone on. + + Only slots with an assigned spool produce an event. A slot Bambuddy + cannot resolve to a spool has no remaining weight it can stand behind, + and guessing one is how the remain percentage would have got in. + """ + from backend.app.models.spool import Spool + + # Time-gated rather than run on every pass. run() sleeps + # _fast_check_interval -- 3 seconds -- on any productive pass, and the + # early-return path counts as productive while an upload is in flight + # (#2602), so an unthrottled check would re-read the whole spool + # collection every 3 seconds for the length of a batch drain. In + # Spoolman mode that is a request storm against a third-party service, + # which is the thing this producer's design note argues against; and + # when Spoolman is down, _get_with_retry burns ~16 s inside + # check_queue holding the scheduler's session, so the outage would + # throttle the print queue itself. A low spool is not a 3-second + # concern -- the idle interval is the natural resolution. + now = time.monotonic() + if now < self._filament_low_next_check: + return + self._filament_low_next_check = now + _FILAMENT_LOW_MIN_INTERVAL + + try: + # on_filament_low defaults to off on every provider, so on most + # installs nobody wants this alert. Without this the check would + # still read every assigned spool each interval -- the whole + # collection over HTTP in Spoolman mode -- for an event that is + # switched off. One query against the provider table settles it + # before any spool work, the same guard the bed-cooled waiter uses. + # No printer_id: this asks whether any provider wants the event at + # all; per-printer scoping is applied when the event is sent. + # + # While nobody wants it, no pass sees a slot go back above its + # threshold, so nothing can re-arm; a spool refilled under the same + # id in that time would stay silenced until a restart. Forgetting + # what was sent is right anyway: whoever switches the event back on + # is told about the spools that are low now. + if not await notification_service._get_providers_for_event(db, "on_filament_low"): + self._notified_filament_low.clear() + return + + global_threshold = await self._get_low_stock_threshold(db) + spoolman_on = await self._get_bool_setting(db, "spoolman_enabled") + + # (printer_id, ams_id, tray_id, spool_id) -> (remaining_pct, threshold, colour name) + slots: dict[tuple[int, int, int, int], tuple[float, float, str | None]] = {} + + if spoolman_on: + slots = await self._filament_low_slots_spoolman(db, global_threshold) + else: + rows = ( + await db.execute( + select(SpoolAssignment, Spool) + .join(Spool, SpoolAssignment.spool_id == Spool.id) + # An archived spool is not stock. The Inventory page's + # Low Stock count skips them (InventoryPage.tsx:1089) + # and get_all_spools without allow_archived already + # excludes them in Spoolman mode, so without this the + # internal path is the only one that alerts on them. + .where(Spool.archived_at.is_(None)) + ) + ).all() + for assignment, spool in rows: + pct = _remaining_percent(spool.label_weight, spool.weight_used) + if pct is None: + continue + threshold = float(spool.low_stock_threshold_pct or global_threshold) + slots[(assignment.printer_id, assignment.ams_id, assignment.tray_id, spool.id)] = ( + pct, + threshold, + spool.color_name, + ) + + if not slots: + # Nothing resolvable this pass. Deliberately not clearing the + # notified set: a Spoolman that is briefly unreachable would + # otherwise re-alert on every spool as soon as it came back. + return + + await self._emit_filament_low(db, slots) + except Exception as e: + logger.warning("Low-filament check failed: %s", e, exc_info=True) + + async def _get_low_stock_threshold(self, db: AsyncSession) -> float: + """The global low-stock percentage, defaulting to the schema's 20.0.""" + raw = (await db.execute(select(Settings).where(Settings.key == "low_stock_threshold"))).scalar_one_or_none() + if raw is None or raw.value is None: + return 20.0 + try: + value = float(raw.value) + except (TypeError, ValueError): + return 20.0 + return value if 0 < value <= 100 else 20.0 + + async def _filament_low_slots_spoolman( + self, db: AsyncSession, global_threshold: float + ) -> dict[tuple[int, int, int, int], tuple[float, float, str | None]]: + """Resolve Spoolman-mode slots to (remaining %, threshold, colour name). + + Spoolman spools carry no per-spool override -- ``low_stock_threshold_pct`` + is a column on Bambuddy's own spool table and has no Spoolman equivalent, + so the global threshold is the only one that applies here. That matches + what the Inventory page already does in this mode. + + One ``get_all_spools`` call covers every slot rather than a request per + spool. Archived spools do not appear -- ``get_all_spools`` excludes them + without ``allow_archived`` -- which is the behaviour the internal path + has to ask for explicitly. + + An unreachable Spoolman returns no slots rather than raising. It is an + ordinary state for a third-party service, not an error in this pass: the + caller's "nothing resolvable" branch already leaves the notified set + alone, which is exactly right here, whereas letting it reach the broad + ``except`` would write a full traceback every time the check runs. + """ + from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool + from backend.app.services.spoolman import SpoolmanUnavailableError, get_spoolman_client + + assignments = (await db.execute(select(SpoolmanSlotAssignment))).scalars().all() + if not assignments: + return {} + + client = await get_spoolman_client() + if client is None: + return {} + + try: + all_spools = await client.get_all_spools() + except SpoolmanUnavailableError as e: + logger.debug("Low-filament check skipped, Spoolman unreachable: %s", e) + return {} + + by_id: dict[int, dict] = {} + for raw in all_spools: + raw_id = raw.get("id") + if isinstance(raw_id, int): + by_id[raw_id] = raw + + slots: dict[tuple[int, int, int, int], tuple[float, float, str | None]] = {} + for assignment in assignments: + raw = by_id.get(assignment.spoolman_spool_id) + if raw is None: + continue + try: + mapped = _map_spoolman_spool(raw) + except ValueError: + continue + pct = _remaining_percent(mapped.get("label_weight"), mapped.get("weight_used")) + if pct is None: + continue + key = (assignment.printer_id, assignment.ams_id, assignment.tray_id, assignment.spoolman_spool_id) + slots[key] = (pct, global_threshold, mapped.get("color_name")) + return slots + + async def _emit_filament_low( + self, db: AsyncSession, slots: dict[tuple[int, int, int, int], tuple[float, float, str | None]] + ) -> None: + """Send one notification per slot that has newly crossed its threshold.""" + printer_names: dict[int, str] = {} + for key, (pct, threshold, color) in slots.items(): + printer_id, ams_id, tray_id, _spool_id = key + if pct >= threshold: + # Back above the line: re-arm rather than expire on a timer, so a + # refilled slot can alert again and a spool sitting just under the + # threshold stays quiet. + self._notified_filament_low.discard(key) + continue + if key in self._notified_filament_low: + continue + # Marked before sending, which is a choice rather than the only + # option, and it is the opposite failure mode from the one the + # debounce argues against: one transient provider failure loses this + # alert until the spool goes back above the threshold and crosses it + # again. Marking after a successful send would trade that for + # re-alerting on every pass while a provider is down -- which is the + # repetition the whole debounce exists to prevent, and the louder of + # the two failures. A spool that is low stays low, so the next real + # signal is not far away; a provider stuck retrying is a signal that + # never stops. + self._notified_filament_low.add(key) + + if printer_id not in printer_names: + printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none() + if printer is None: + continue + printer_names[printer_id] = printer.name + try: + await notification_service.on_filament_low( + printer_id, + printer_names[printer_id], + _ams_slot_label(ams_id, tray_id), + int(pct), + db, + color=color, + ) + except Exception as e: + logger.warning("Low-filament notification failed for slot %s: %s", key, e) + async def _check_auto_drying( self, db: AsyncSession, diff --git a/backend/tests/unit/services/test_filament_low_producer_2913.py b/backend/tests/unit/services/test_filament_low_producer_2913.py new file mode 100644 index 000000000..878f58943 --- /dev/null +++ b/backend/tests/unit/services/test_filament_low_producer_2913.py @@ -0,0 +1,627 @@ +"""The "Low Filament" notification has a producer now (issue #2913). + +Every layer of this feature existed -- a column on notification_providers, a +schema field, a route, a message template, thirteen locales and a UI toggle -- +except the thing that fires it. `_get_providers_for_event(db, "on_filament_low")` +appeared only inside the method that would be called, so the toggle could be +switched on and could never produce a notification. + +The reason it shipped is visible in the old test file: the only occurrence of +`on_filament_low` in test_notification_service.py is a fixture setting the flag +to False. Nothing ever asserted the event reaches a provider. The last test here +does exactly that. +""" + +import logging +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select + +from backend.app.models.notification import NotificationProvider +from backend.app.models.printer import Printer +from backend.app.models.settings import Settings +from backend.app.models.spool import Spool +from backend.app.models.spool_assignment import SpoolAssignment +from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment +from backend.app.services.print_scheduler import PrintScheduler, _ams_slot_label, _remaining_percent +from backend.app.services.spoolman import SpoolmanUnavailableError + + +async def _printer(db, name: str = "X2D") -> Printer: + p = Printer(name=name, serial_number=f"S-{name}", ip_address="1.1.1.1", access_code="c", model="X2D") + db.add(p) + await db.flush() + return p + + +async def _assigned_spool( + db, printer, *, weight_used: float, threshold_pct: int | None = None, ams=0, tray=0, color_name=None +): + spool = Spool( + material="PLA", + color_name=color_name, + label_weight=1000, + core_weight=250, + weight_used=weight_used, + low_stock_threshold_pct=threshold_pct, + ) + spool.k_profiles = [] + spool.assignments = [] + db.add(spool) + await db.flush() + db.add(SpoolAssignment(printer_id=printer.id, spool_id=spool.id, ams_id=ams, tray_id=tray)) + await db.flush() + return spool + + +# -- the arithmetic --------------------------------------------------------- + + +def test_remaining_percent_matches_the_inventory_page_rule(): + """`remaining = label - used`, as a percentage of label. The same expression + the Low Stock count uses, so the alert and the badge cannot disagree.""" + assert _remaining_percent(1000, 850.0) == pytest.approx(15.0) + assert _remaining_percent(1000, 0.0) == pytest.approx(100.0) + + +def test_remaining_percent_is_none_without_a_label_weight(): + """A spool that cannot say how full it started cannot say how empty it is.""" + assert _remaining_percent(0, 100.0) is None + assert _remaining_percent(None, 100.0) is None + + +def test_remaining_percent_floors_at_zero_when_overdrawn(): + assert _remaining_percent(1000, 1200.0) == pytest.approx(0.0) + + +def test_slot_labels_are_the_ones_the_notification_side_already_uses(): + """Every kind of slot, against the spelling the rest of the app produces. + + The four cases after the first two are the ones the hand-rolled version got + wrong: 254 is a real ams_id (InventoryPage.tsx:286, usage_tracker.py:55) and + fell into its ``>= 128`` branch as ``HT-`` plus ``chr(191)``; an external + assignment stores 255 with tray_id choosing the side, so both externals came + out as one label and a two-external printer could not tell them apart; and + an A2L AMS-Lite normalises to unit 6, which read as ``AMS-G``. + """ + assert _ams_slot_label(0, 0) == "A1" + assert _ams_slot_label(1, 3) == "B4" + assert _ams_slot_label(128, 0) == "HT-A" + assert _ams_slot_label(254, 0) == "Ext-L" + assert _ams_slot_label(255, 0) == "Ext-L" + assert _ams_slot_label(255, 1) == "Ext-R" + assert _ams_slot_label(6, 0) == "Lite-1" + + +# -- the producer ----------------------------------------------------------- + + +@pytest.fixture +def scheduler(): + return PrintScheduler() + + +async def _pass(scheduler, db): + """Run one low-filament check, stepping over the inter-pass time gate. + + ``_check_filament_low`` refuses to run again within + ``_FILAMENT_LOW_MIN_INTERVAL``, so consecutive calls in a test would be + swallowed by the gate and every multi-pass assertion below would pass + without exercising what it names. The gate has its own test. + """ + scheduler._filament_low_next_check = 0.0 + await scheduler._check_filament_low(db) + + +@pytest.fixture +def notify(): + """The service, replaced, with one provider that wants the event. + + Every test below that is about the producer assumes someone wants the alert; + the provider guard in front of the check has its own tests further down, + which run the real provider query. + """ + with patch("backend.app.services.print_scheduler.notification_service") as ns: + ns.on_filament_low = AsyncMock() + ns._get_providers_for_event = AsyncMock(return_value=[MagicMock()]) + yield ns + + +@pytest.mark.asyncio +async def test_fires_when_an_assigned_spool_is_below_the_threshold(db_session, scheduler, notify): + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0) # 15% left, default threshold 20 + await db_session.commit() + + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_awaited_once() + args = notify.on_filament_low.await_args.args + assert args[0] == printer.id + assert args[1] == "X2D" + assert args[2] == "A1" + assert args[3] == 15 + + +@pytest.mark.asyncio +async def test_the_spool_colour_is_passed_for_the_template(db_session, scheduler, notify): + """on_filament_low has always accepted color= and the filament_low template + lists {color} as a variable; with nothing passing it, the variable rendered + empty in every custom template.""" + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0, color_name="Jade White") + await db_session.commit() + + await _pass(scheduler, db_session) + + assert notify.on_filament_low.await_args.kwargs["color"] == "Jade White" + + +@pytest.mark.asyncio +async def test_stays_quiet_above_the_threshold(db_session, scheduler, notify): + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=500.0) # 50% left + await db_session.commit() + + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_slot_with_no_assigned_spool_produces_nothing(db_session, scheduler, notify): + """Guessing a remaining weight for an unassigned slot is how the AMS remain + percentage would have got back in.""" + await _printer(db_session) + await db_session.commit() + + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_does_not_repeat_on_the_next_pass(db_session, scheduler, notify): + """The debounce. A spool sitting under the threshold must not alert every + 30 seconds until it is changed.""" + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0) + await db_session.commit() + + await _pass(scheduler, db_session) + await _pass(scheduler, db_session) + await _pass(scheduler, db_session) + + assert notify.on_filament_low.await_count == 1 + + +@pytest.mark.asyncio +async def test_re_arms_once_the_slot_goes_back_above_the_threshold(db_session, scheduler, notify): + """Cleared by the value going back up, not by a timer -- so a refilled slot + can alert again, and a spool hovering at the boundary cannot spam.""" + printer = await _printer(db_session) + spool = await _assigned_spool(db_session, printer, weight_used=850.0) + await db_session.commit() + + await _pass(scheduler, db_session) + assert notify.on_filament_low.await_count == 1 + + # Refilled — a fresh spool put on the same slot. + spool.weight_used = 0.0 + await db_session.commit() + await _pass(scheduler, db_session) + assert notify.on_filament_low.await_count == 1 + + # And down again. + spool.weight_used = 900.0 + await db_session.commit() + await _pass(scheduler, db_session) + assert notify.on_filament_low.await_count == 2 + + +@pytest.mark.asyncio +async def test_debounce_is_per_slot_not_per_printer(db_session, scheduler, notify): + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0, ams=0, tray=0) + await _assigned_spool(db_session, printer, weight_used=900.0, ams=0, tray=1) + await db_session.commit() + + await _pass(scheduler, db_session) + + assert notify.on_filament_low.await_count == 2 + slots = {call.args[2] for call in notify.on_filament_low.await_args_list} + assert slots == {"A1", "A2"} + + +@pytest.mark.asyncio +async def test_per_spool_override_beats_the_global_threshold(db_session, scheduler, notify): + """A spool marked as needing 60% left alerts at 50%, where the global 20% + would have stayed quiet. Same precedence the Inventory page uses.""" + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=500.0, threshold_pct=60) + await db_session.commit() + + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_awaited_once() + assert notify.on_filament_low.await_args.args[3] == 50 + + +@pytest.mark.asyncio +async def test_global_setting_is_honoured(db_session, scheduler, notify): + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=600.0) # 40% left + db_session.add(Settings(key="low_stock_threshold", value="50")) + await db_session.commit() + + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_a_failing_provider_does_not_break_the_pass(db_session, scheduler, notify): + """A notification provider being down must not take the scheduler with it.""" + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0) + await db_session.commit() + notify.on_filament_low.side_effect = RuntimeError("provider down") + + await _pass(scheduler, db_session) # must not raise + + notify.on_filament_low.assert_awaited_once() + + +# -- the pass is time-gated ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_second_pass_inside_the_interval_does_no_work(db_session, scheduler, notify): + """The gate, which every multi-pass test above steps over deliberately. + + run() sleeps _fast_check_interval -- 3 seconds -- on any productive pass, + and the early-return path counts as productive while an upload is in flight + (#2602). Ungated, this check would re-read every assigned spool on that + cadence for the length of a batch drain, which in Spoolman mode means the + whole collection over HTTP against a third-party service. + """ + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0) + await db_session.commit() + + with patch.object(scheduler, "_get_low_stock_threshold", new_callable=AsyncMock) as work: + work.return_value = 20.0 + await scheduler._check_filament_low(db_session) + await scheduler._check_filament_low(db_session) + await scheduler._check_filament_low(db_session) + + # Not "no notification" -- the debounce would give that too. No work at all. + assert work.await_count == 1 + + +@pytest.mark.asyncio +async def test_an_archived_spool_is_not_stock(db_session, scheduler, notify): + """Archiving a spool takes it out of the Low Stock count (InventoryPage.tsx:1089). + + Spoolman mode gets this for free -- get_all_spools without allow_archived + does not return them -- so without the explicit filter the internal path + would be the only mode that alerts on a spool the user has retired. + """ + printer = await _printer(db_session) + spool = await _assigned_spool(db_session, printer, weight_used=850.0) + spool.archived_at = datetime(2026, 8, 1, 12, 0, 0) + await db_session.commit() + + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_not_awaited() + + +# -- Spoolman mode ---------------------------------------------------------- + + +def _spoolman_spool(spool_id: int, remaining_weight: float, label_weight: int = 1000, color_name=None) -> dict: + """A raw Spoolman spool, in the shape _map_spoolman_spool reads.""" + filament = {"id": 1, "name": "PLA Basic", "material": "PLA", "weight": label_weight, "vendor": {"name": "X"}} + if color_name is not None: + filament["color_name"] = color_name + return { + "id": spool_id, + "remaining_weight": remaining_weight, + "used_weight": label_weight - remaining_weight, + "filament": filament, + } + + +async def _spoolman_slot(db, printer, spool_id: int, *, ams=0, tray=0): + db.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=ams, tray_id=tray, spoolman_spool_id=spool_id)) + db.add(Settings(key="spoolman_enabled", value="true")) + await db.flush() + + +def _spoolman_client(spools, *, unavailable: bool = False): + client = MagicMock() + if unavailable: + client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Cannot reach Spoolman")) + else: + client.get_all_spools = AsyncMock(return_value=spools) + return client + + +@pytest.mark.asyncio +async def test_spoolman_mode_alerts_from_the_collection_read(db_session, scheduler, notify): + """The half with the client in it, which had no coverage at all. + + The threshold is meant to mean the same thing in both inventory modes, so + the mode that reaches a third-party service for its numbers needs its own + tests rather than inheriting the internal path's. + """ + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7, ams=0, tray=1) + await db_session.commit() + client = _spoolman_client([_spoolman_spool(7, remaining_weight=150.0, color_name="Jade White")]) # 15% left + + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)): + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_awaited_once() + args = notify.on_filament_low.await_args.args + assert args[2] == "A2" + assert args[3] == 15 + assert notify.on_filament_low.await_args.kwargs["color"] == "Jade White" + # One request for the whole collection, not one per assigned slot. + client.get_all_spools.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_a_spoolman_slot_whose_spool_is_gone_is_skipped(db_session, scheduler, notify): + """A spool deleted in Spoolman leaves the assignment row behind. It has no + remaining weight, so it produces nothing rather than a guess.""" + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7) + await db_session.commit() + client = _spoolman_client([_spoolman_spool(99, remaining_weight=150.0)]) + + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)): + await _pass(scheduler, db_session) + + notify.on_filament_low.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_an_unreachable_spoolman_resolves_nothing_and_stays_quiet(db_session, scheduler, notify, caplog): + """An outage is an ordinary state for a third-party service, not an error here. + + The assertion is the log, not the silence. Letting SpoolmanUnavailableError + reach _check_filament_low's broad except produces the same absence of + notifications -- so "nothing was sent" cannot tell the two apart -- while + writing a full traceback on every pass for the length of the outage. What + the catch buys is that it is handled where it is expected. + """ + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7) + await db_session.commit() + client = _spoolman_client(None, unavailable=True) + + with ( + caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"), + patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)), + ): + await _pass(scheduler, db_session) # must not raise + + notify.on_filament_low.assert_not_awaited() + assert "Low-filament check failed" not in caplog.text + + +@pytest.mark.asyncio +async def test_an_outage_does_not_re_alert_everything_when_it_clears(db_session, scheduler, notify): + """The reason a pass that resolves nothing leaves the notified set alone. + + Clearing the set on an empty pass would make a brief Spoolman outage + indistinguishable from every spool being refilled, and the alerts would all + fire again the moment it came back. + """ + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7) + await db_session.commit() + low = [_spoolman_spool(7, remaining_weight=150.0)] + + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(low))): + await _pass(scheduler, db_session) + assert notify.on_filament_low.await_count == 1 + + outage = _spoolman_client(None, unavailable=True) + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=outage)): + await _pass(scheduler, db_session) + + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(low))): + await _pass(scheduler, db_session) + assert notify.on_filament_low.await_count == 1 + + +@pytest.mark.asyncio +async def test_a_replacement_spool_in_the_same_slot_can_alert(db_session, scheduler, notify): + """Why the notified key carries the spool id. + + Pull a low spool and put a different part-used one in the same slot. The + key survives the pass where the slot resolves to nothing -- deliberately -- + and the replacement never goes above the threshold, so keyed on the slot + alone it could never re-arm and the second spool would run out silently. + """ + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7) + await db_session.commit() + + first = _spoolman_client([_spoolman_spool(7, remaining_weight=150.0)]) + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=first)): + await _pass(scheduler, db_session) + assert notify.on_filament_low.await_count == 1 + + # The slot resolves to nothing for a pass while the roll is swapped. + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client([]))): + await _pass(scheduler, db_session) + + assignment = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().one() + assignment.spoolman_spool_id = 8 + await db_session.commit() + + second = _spoolman_client([_spoolman_spool(8, remaining_weight=100.0)]) # 10% left + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=second)): + await _pass(scheduler, db_session) + + assert notify.on_filament_low.await_count == 2 + assert notify.on_filament_low.await_args.args[3] == 10 + + +# -- nobody wants the alert ------------------------------------------------ + + +async def _provider(db, *, enabled: bool, on_filament_low: bool) -> None: + db.add( + NotificationProvider( + name=f"p-{enabled}-{on_filament_low}", + provider_type="ntfy", + config="{}", + enabled=enabled, + on_filament_low=on_filament_low, + ) + ) + await db.flush() + + +@pytest.fixture +def real_providers(): + """The real service, with only the send replaced. + + The guard is one query against notification_providers, so these tests run + it for real rather than mocking the answer -- otherwise a guard asking about + the wrong event, or ignoring ``enabled``, would pass. + """ + from backend.app.services.print_scheduler import notification_service + + with patch.object(notification_service, "on_filament_low", new_callable=AsyncMock) as send: + yield send + + +@pytest.mark.asyncio +async def test_no_spoolman_call_when_no_provider_wants_the_event(db_session, scheduler, real_providers): + """on_filament_low defaults to off, so on most installs nobody wants this. + + Unguarded, every Spoolman install with slot assignments read its whole spool + collection every interval, forever, for an alert that was switched off. The + two providers here are the two ways of not wanting it: enabled with the + event off, and the event on but the provider disabled. + """ + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7) + await _provider(db_session, enabled=True, on_filament_low=False) + await _provider(db_session, enabled=False, on_filament_low=True) + await db_session.commit() + get_client = AsyncMock(return_value=_spoolman_client([_spoolman_spool(7, remaining_weight=150.0)])) + + with patch("backend.app.services.spoolman.get_spoolman_client", get_client): + await _pass(scheduler, db_session) + + get_client.assert_not_awaited() + real_providers.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_internal_mode_does_no_spool_work_when_no_provider_wants_the_event(db_session, scheduler, real_providers): + """Same guard, internal mode: no spool query at all, not just no send.""" + printer = await _printer(db_session) + await _assigned_spool(db_session, printer, weight_used=850.0) + await db_session.commit() + + with patch.object(scheduler, "_get_low_stock_threshold", new_callable=AsyncMock) as work: + work.return_value = 20.0 + await _pass(scheduler, db_session) + + work.assert_not_awaited() + real_providers.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_one_provider_wanting_the_event_is_enough(db_session, scheduler, real_providers): + """The other side of the guard, through the same real query: one enabled + provider with the event on and the check runs. Without this, a guard that + always returned would pass the two tests above.""" + printer = await _printer(db_session) + await _spoolman_slot(db_session, printer, 7) + await _provider(db_session, enabled=True, on_filament_low=False) + await _provider(db_session, enabled=True, on_filament_low=True) + await db_session.commit() + client = _spoolman_client([_spoolman_spool(7, remaining_weight=150.0)]) + + with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)): + await _pass(scheduler, db_session) + + client.get_all_spools.assert_awaited_once() + real_providers.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_switching_the_event_off_and_on_again_re_arms(db_session, scheduler, real_providers): + """While the event is off no pass runs, so none can see a slot go back above + its threshold and re-arm it. A spool refilled under the same id meanwhile + would stay silenced once the event is back on. Switching it off forgets what + was sent; switching it on reports the spools that are low now.""" + printer = await _printer(db_session) + spool = await _assigned_spool(db_session, printer, weight_used=850.0) + provider = NotificationProvider(name="p", provider_type="ntfy", config="{}", enabled=True, on_filament_low=True) + db_session.add(provider) + await db_session.commit() + + await _pass(scheduler, db_session) + assert real_providers.await_count == 1 + + # Off; refilled and run low again while no pass was looking; on again. + provider.on_filament_low = False + await db_session.commit() + await _pass(scheduler, db_session) + spool.weight_used = 900.0 + provider.on_filament_low = True + await db_session.commit() + await _pass(scheduler, db_session) + + assert real_providers.await_count == 2 + + +# -- the event actually reaches a provider ---------------------------------- + + +@pytest.mark.asyncio +async def test_the_event_reaches_a_provider_with_the_toggle_on(): + """The assertion that was missing, and the reason this shipped unfired. + + Everything above proves the producer calls the service. This proves the + service does not drop it: a provider with on_filament_low enabled is handed + the rendered message. + """ + from backend.app.services.notification_service import NotificationService + + service = NotificationService() + provider = MagicMock() + provider.id = 1 + provider.on_filament_low = True + + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): + mock_get.return_value = [provider] + mock_build.return_value = ("Filament Low", "X2D A1 is at 15%") + + await service.on_filament_low(1, "X2D", "A1", 15, AsyncMock(), color="Jade White") + + mock_get.assert_awaited_once() + assert mock_get.await_args.args[1] == "on_filament_low" + mock_send.assert_awaited_once() + variables = mock_send.await_args.kwargs["variables"] + assert variables["printer"] == "X2D" + assert variables["slot"] == "A1" + assert variables["remaining_percent"] == "15" + assert variables["color"] == "Jade White"