Give the Low Filament notification something that fires it (issue #2913) (#2940)

This commit is contained in:
maziggy
2026-09-30 16:22:49 +02:00
3 changed files with 911 additions and 2 deletions
+2 -2
View File
@@ -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 "",
}
+282
View File
@@ -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,
@@ -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"