Keep both modes' slot assignments across an inventory mode switch (issue #2812)

Turning Spoolman mode on ran an unfiltered delete(SpoolAssignment) across every
    printer. Turning it straight back off cleared the other table instead, so the
    two directions were symmetric in code and one-way in effect, and the setting
    auto-saves on a 500 ms debounce with no save button and no confirmation.
    Opening the settings page to see what the option did was enough to destroy the
    configuration: the reporter's log shows four toggles in 85 seconds, which is
    someone looking and reverting, and the assignments never came back.

    The deletion was not careless. Checks that read both assignment tables would
    otherwise let a row in the mode you are not using answer for the mode you are,
    which is how #1473 was fixed, and emptying the inactive table made that
    impossible by construction. The cost was that the guarantee was bought with the
    user's data. That decision belongs to the readers -- the mode is a property of
    the install, not of the rows -- so spoolman_owns_assignments now answers it and
    nothing is deleted on a toggle. Each mode keeps its own assignments and
    switching is reversible. Existing installs need no migration: their inactive
    table is already empty, because it was being emptied.

    Six sites had to be told which mode they meant, and only two of them are the
    reads you would guess at, the missing-assignment notification and the queue cost
    estimate. The per-slot K-profile lookup consults the built-in table first and,
    on a hit with no matching profile, deliberately stops rather than falling
    through to Spoolman, so a leftover row would have shadowed the Spoolman binding
    for that slot -- the symptom #1556 reported from the other direction.
    configure_ams_slot *writes* a K-profile against whichever table answers first,
    so the same leftover would have filed a calibration against a spool the printer
    is not drawing on and never written the local one, leaving a calibration that
    appeared to succeed and then did not apply.

    The auto-unlink pass in on_ams_change is the one that would have made this
    change worthless. It drops any assignment whose tray no longer matches the
    fingerprint it recorded, and it ends in db.delete. Ungated, it would have
    removed the preserved rows one slot at a time as the AMS contents changed under
    the other mode -- the same loss, arriving slowly enough not to be connected to
    the toggle that caused it.

    The sixth is the built-in remaining-weight fallback inside the Spoolman AMS
    sync, and it is deliberately left inert rather than woken up. It could never
    fire while the table it reads was being emptied, it is keyed by slot rather than
    by spool, and create_spool writes remaining_weight unconditionally where the
    update path does not -- so preserving the rows would have seeded a stale figure
    into a brand new Spoolman spool the first time a tray reported an unusable
    remain%. The query stays, gated off, so the intent survives for whoever
    revisits the cross-mode fallback.

    Separately, a print that could not debit a spool said nothing about it, and that
    is what turned a mis-click into lost filament. The reporter's print was already
    running when they toggled. At completion it resolved its 3MF, read its
    per-filament grams, resolved its tray, and then skipped the debit because the
    assignment row no longer existed -- logged at INFO, invisible under the default
    log level, while the completion notification fired as usual. 65.49 g was never
    deducted and they only noticed because a spool's remaining weight looked wrong.
    _resolve_spool_id_for_tray has no tag or fingerprint fallback, so there was
    nothing else to catch it.

    The skip is now a warning naming the grams, and a completed print that failed to
    charge a tray it drew from raises the missing-spool-assignment notification. The
    print-start check cannot cover this and was right to stay quiet: the assignments
    existed when it ran. The two are different statements -- the first says the
    weight may not be tracked, the second says it was not -- so a print warned at
    start will notify twice, which is the right trade. Collected across the print
    rather than fired per slot, and given the caller's session, because this runs
    inside on_print_complete's transaction and opening a second one to read the
    printer's name would deadlock against it on SQLite. This is independent of the
    toggle and catches any other cause of an assignment disappearing mid-print.
This commit is contained in:
maziggy
2026-08-26 10:21:59 +02:00
13 changed files with 758 additions and 122 deletions
+25 -10
View File
@@ -2935,15 +2935,27 @@ async def configure_ams_slot(
# tell, and on those machines extruder 0 is the only one there is.
kp_extruder = resolved_extruder if resolved_extruder is not None else 0
# Spoolman SlotAssignment first — has UniqueConstraint, idempotent.
sm_result = await db.execute(
select(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.printer_id == printer_id,
SpoolmanSlotAssignment.ams_id == ams_id,
SpoolmanSlotAssignment.tray_id == tray_id,
# Only the active mode's assignment table decides where this
# K-profile is stored. Reading Spoolman first and falling through
# was safe while the inactive table was emptied on every mode
# toggle; nothing is emptied since #2812, so a leftover Spoolman
# row in built-in mode would file the calibration against a spool
# the printer is not using and never write the local profile —
# the calibration would appear to succeed and then not apply.
from backend.app.services.inventory_mode import spoolman_owns_assignments
spoolman_mode = await spoolman_owns_assignments(db)
sm_assignment = None
if spoolman_mode:
# Spoolman SlotAssignment — has UniqueConstraint, idempotent.
sm_result = await db.execute(
select(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.printer_id == printer_id,
SpoolmanSlotAssignment.ams_id == ams_id,
SpoolmanSlotAssignment.tray_id == tray_id,
)
)
)
sm_assignment = sm_result.scalar_one_or_none()
sm_assignment = sm_result.scalar_one_or_none()
if sm_assignment:
existing = await db.execute(
select(SpoolmanKProfile).where(
@@ -2981,8 +2993,11 @@ async def configure_ams_slot(
tray_id,
cali_idx,
)
else:
# Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first())
elif not spoolman_mode:
# Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first()).
# Skipped in Spoolman mode even when a local row survives: the
# profile would be filed against a spool this printer is not
# drawing on, and the mode's own table has nothing to bind to.
local_result = await db.execute(
select(SpoolAssignment)
.options(selectinload(SpoolAssignment.spool))
+19 -17
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import delete, func, select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
@@ -552,22 +552,24 @@ async def update_spoolman_settings(
now_enabled = new_val == "true"
await set_setting(db, "spoolman_enabled", new_val)
# Switching to Spoolman: clear built-in inventory slot assignments
if not was_enabled and now_enabled:
from backend.app.models.spool_assignment import SpoolAssignment
result = await db.execute(delete(SpoolAssignment))
logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount)
# Switching back to internal mode: clear Spoolman slot assignments — the
# symmetric counterpart of the clear above. Without this, stale
# spoolman_slot_assignments rows linger and would wrongly count as
# "assigned" in any mode-agnostic check (e.g. the missing-spool-
# assignment notification, which unions both tables — #1473).
elif was_enabled and not now_enabled:
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
result = await db.execute(delete(SpoolmanSlotAssignment))
logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
# Nothing is deleted on a mode change (#2812). Each mode keeps its slot
# assignments in its own table, so both can hold rows at once and the
# toggle is reversible: switching to Spoolman to see what it does, then
# switching back, returns you to the assignments you had.
#
# This used to empty the other mode's table on every toggle. The reason
# was real -- checks that read both tables would let a row in the mode
# you are not using answer for the mode you are -- but the cost was that
# inspecting a mode destroyed your configuration, with no confirmation
# and no way back, and the deletion was unfiltered across every printer.
# The readers that could be confused now ask which mode is active
# (``spoolman_owns_assignments``), which is where that decision belongs:
# the mode is a property of the install, not of the rows.
if was_enabled != now_enabled:
logger.info(
"Inventory mode switched to %s; slot assignments in both tables kept",
"Spoolman" if now_enabled else "built-in",
)
if "spoolman_url" in settings:
await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
if "spoolman_sync_mode" in settings:
+42 -19
View File
@@ -1953,17 +1953,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
from backend.app.api.routes.inventory import _find_tray_in_ams_data
from backend.app.models.spool import Spool as _Spool
from backend.app.models.spool_assignment import SpoolAssignment as SA
from backend.app.services.inventory_mode import spoolman_owns_assignments
result = await db.execute(
select(SA)
.where(SA.printer_id == printer_id)
.options(selectinload(SA.spool).selectinload(_Spool.k_profiles))
)
# Built-in assignments only. Since #2812 they survive a switch to
# Spoolman mode rather than being deleted by it, and this pass ends
# in ``db.delete`` — left ungated it would unlink them one slot at a
# time as the AMS contents changed under the other mode, undoing the
# preservation more slowly but just as completely.
assignments = []
if not await spoolman_owns_assignments(db):
result = await db.execute(
select(SA)
.where(SA.printer_id == printer_id)
.options(selectinload(SA.spool).selectinload(_Spool.k_profiles))
)
assignments = result.scalars().all()
# ``printing_now`` (top of this function) keeps a runout from
# unlinking the spool that fed the print — the next idle-time pass
# unlinks it if the user really did take it out.
stale = []
for assignment in result.scalars().all():
for assignment in assignments:
# External spool assignments (ams_id=255) live in vt_tray, not AMS data
if assignment.ams_id == 255:
ps = printer_manager.get_status(printer_id)
@@ -2521,21 +2530,35 @@ async def on_ams_change(printer_id: int, ams_data: list):
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.services.inventory_mode import spoolman_owns_assignments
# Built-in remaining weight, used by sync_ams_tray only when the
# firmware reports an unusable remain%/tray_weight for a slot.
#
# Left empty since #2812. This block runs in Spoolman mode only,
# and until then the built-in table was emptied on the switch, so
# there was never anything here to read and the fallback was inert.
# Preserving those rows makes it live again, and it is keyed by slot
# rather than by spool: after a mode switch the tray may well hold
# different filament, and ``create_spool`` writes ``remaining_weight``
# unconditionally, so a stale figure would be seeded into a brand new
# Spoolman spool. Deliberately kept inert rather than deleted, so the
# intent survives for whoever revisits the cross-mode fallback.
inventory_weights: dict[tuple[int, int], float] = {}
try:
assign_result = await db.execute(
select(SpoolAssignment)
.options(selectinload(SpoolAssignment.spool))
.where(SpoolAssignment.printer_id == printer_id)
)
for assignment in assign_result.scalars().all():
spool = assignment.spool
if spool and spool.label_weight > 0:
remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining
except Exception as e:
logger.warning("Could not load inventory weights for printer %s: %s", printer_id, e)
if not await spoolman_owns_assignments(db):
try:
assign_result = await db.execute(
select(SpoolAssignment)
.options(selectinload(SpoolAssignment.spool))
.where(SpoolAssignment.printer_id == printer_id)
)
for assignment in assign_result.scalars().all():
spool = assignment.spool
if spool and spool.label_weight > 0:
remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining
except Exception as e:
logger.warning("Could not load inventory weights for printer %s: %s", printer_id, e)
# Load existing Spoolman slot assignments for the no-RFID fallback path
spoolman_slot_map: dict[tuple[int, int], int] = {}
+42
View File
@@ -0,0 +1,42 @@
"""Which table holds a printer's slot assignments.
Bambuddy keeps AMS slot assignments in two places: ``spool_assignment`` for the
built-in inventory and ``spoolman_slot_assignments`` for Spoolman. Exactly one
of them describes reality at any moment, and which one is a user setting.
Until #2812 the two were kept from overlapping by emptying the inactive table
whenever the mode toggled, which made merely looking at the other mode destroy
the configuration you had. Nothing is deleted now, so both tables can hold rows
at once and every reader has to say which one it means.
This is deliberately a module of its own rather than a helper on the settings
routes: the readers are services, and importing an API route module from a
service to answer a one-key question invites an import cycle. Several call
sites already carry their own private copy of this predicate for that reason
(``filament_deficit``, ``print_scheduler``, ``inventory``); those are unchanged
and correct, and are only worth folding in here if they are touched anyway.
"""
import logging
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
async def spoolman_owns_assignments(db: AsyncSession) -> bool:
"""True when ``spoolman_slot_assignments`` is the table that counts.
Fails closed to the built-in inventory: a setting that cannot be read is
not evidence that the user switched modes, and treating an unreadable
setting as "Spoolman" would make a built-in install look as though every
tray were unassigned.
"""
try:
from backend.app.api.routes.settings import get_setting
value = await get_setting(db, "spoolman_enabled")
return bool(value) and value.lower() == "true"
except Exception as exc: # noqa: BLE001 — a mode probe must not raise into its callers
logger.debug("Could not read spoolman_enabled, assuming built-in inventory: %s", exc)
return False
+6 -1
View File
@@ -12,6 +12,7 @@ from backend.app.core.config import settings
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.services.inventory_mode import spoolman_owns_assignments
from backend.app.utils import threemf_tools
from backend.app.utils.safe_path import safe_join_under
@@ -135,7 +136,11 @@ async def estimate_queue_source_cost(
default_cost = await _default_cost_per_kg(db)
cost_by_tray: dict[int, float | None] = {}
mapping = _parse_mapping(ams_mapping)
if printer_id is not None and mapping:
# Built-in spool prices only. In Spoolman mode the built-in table may still
# hold rows from before the user switched -- nothing clears it since #2812 --
# and pricing an estimate from a spool the printer is not drawing on would
# be worse than the default rate this falls back to.
if printer_id is not None and mapping and not await spoolman_owns_assignments(db):
assignments = (
(
await db.execute(
+24 -9
View File
@@ -22,6 +22,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.services.inventory_mode import spoolman_owns_assignments
@dataclass(frozen=True)
@@ -51,18 +52,29 @@ async def find_slot_kprofile_for_extruder(
profile for this nozzle — an operator who calibrated only one side should
keep the binding they set by hand rather than have it swapped for a guess.
Local spools take priority over Spoolman, matching the rest of the
K-profile cascade.
Only the table the current inventory mode uses is consulted. Before #2812
the inactive one was emptied on every mode toggle, so reading the built-in
table first and stopping on a hit was safe -- there could be nothing in it
to stop on. Nothing is emptied now, and a leftover built-in row would
otherwise shadow the Spoolman assignment for the slot, returning that
spool's profile or, on the deliberate stop below, no profile at all. That
is the symptom #1556 reported from the other direction.
"""
spoolman_mode = await spoolman_owns_assignments(db)
assignment = (
await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
None
if spoolman_mode
else (
await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
)
).scalar_one_or_none()
).scalar_one_or_none()
)
if assignment is not None:
profile = (
@@ -92,6 +104,9 @@ async def find_slot_kprofile_for_extruder(
# falling through to Spoolman would answer for a different spool.
return None
if not spoolman_mode:
return None
sm_assignment = (
await db.execute(
select(SpoolmanSlotAssignment).where(
@@ -6,6 +6,7 @@ from backend.app.models.printer import Printer
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.services.bambu_mqtt import PrinterState
from backend.app.services.inventory_mode import spoolman_owns_assignments
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import printer_manager
@@ -131,52 +132,94 @@ async def notify_missing_spool_assignments_on_print_start(
printer = await db.get(Printer, printer_id)
printer_name = printer.name if printer else f"Printer {printer_id}"
# A tray is "assigned" if it has a row in EITHER table: the legacy
# spool_assignment table (internal-inventory mode) or
# spoolman_slot_assignments (Spoolman mode — the binding
# source-of-truth since #1119). Querying only the legacy table
# flagged every used tray as missing on every Spoolman-mode print
# (#1473). Both tables expose printer_id / ams_id / tray_id in the
# same shape, so _global_tray_from_assignment works on either.
legacy_rows = (
await db.execute(SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id))
).fetchall()
spoolman_rows = (
await db.execute(
SpoolmanSlotAssignment.__table__.select().where(SpoolmanSlotAssignment.printer_id == printer_id)
)
).fetchall()
assigned_global_trays = {
_global_tray_from_assignment(row.ams_id, row.tray_id) for row in (*legacy_rows, *spoolman_rows)
}
# A tray is "assigned" if it has a row in the table the current
# mode uses. Both expose printer_id / ams_id / tray_id in the same
# shape, so _global_tray_from_assignment works on either.
#
# This read both tables and unioned them until #2812. That was
# correct while the inactive table was emptied on every mode
# toggle -- it is how #1473 was fixed, where querying only the
# legacy table flagged every tray as missing on a Spoolman print.
# Nothing is emptied now, so a union would let a leftover row in
# the mode you are *not* using vouch for a tray that has no
# assignment in the mode you are, and this notification exists
# precisely to catch that tray.
table = SpoolmanSlotAssignment if await spoolman_owns_assignments(db) else SpoolAssignment
rows = (await db.execute(table.__table__.select().where(table.printer_id == printer_id))).fetchall()
assigned_global_trays = {_global_tray_from_assignment(row.ams_id, row.tray_id) for row in rows}
missing_global = sorted(used_global_trays - assigned_global_trays)
if not missing_global:
return
state = printer_manager.get_status(printer_id)
missing_slots = []
for global_id in missing_global:
profile, color = _tray_profile_and_color_for_global_id(state, global_id)
missing_slots.append(
{
"slot": _slot_label_from_global_tray(global_id),
"profile": profile,
"color": color,
}
)
await ws_manager.send_missing_spool_assignment(
printer_id=printer_id,
printer_name=printer_name,
missing_slots=missing_slots,
)
await notification_service.on_print_missing_spool_assignment(
printer_id=printer_id,
printer_name=printer_name,
missing_slots=missing_slots,
db=db,
)
await _send_missing_assignment_notification(printer_id, printer_name, missing_global, db)
except Exception as e:
logger.warning("Missing spool-assignment notification failed: %s", e)
async def _send_missing_assignment_notification(
printer_id: int,
printer_name: str,
missing_global: list[int],
db,
) -> None:
"""Describe the unassigned trays and push them to the UI and the providers."""
state = printer_manager.get_status(printer_id)
missing_slots = []
for global_id in missing_global:
profile, color = _tray_profile_and_color_for_global_id(state, global_id)
missing_slots.append(
{
"slot": _slot_label_from_global_tray(global_id),
"profile": profile,
"color": color,
}
)
await ws_manager.send_missing_spool_assignment(
printer_id=printer_id,
printer_name=printer_name,
missing_slots=missing_slots,
)
await notification_service.on_print_missing_spool_assignment(
printer_id=printer_id,
printer_name=printer_name,
missing_slots=missing_slots,
db=db,
)
async def notify_missing_spool_assignments_on_print_complete(
printer_id: int,
missing_global_trays: list[int],
db,
logger: logging.Logger,
) -> None:
"""Say so when a finished print could not debit a tray it drew from (#2812).
The print-start check above is predictive: it reads the mapping before the
job runs and warns about trays that have no assignment yet. It cannot cover
an assignment that disappears *during* a print, and nothing re-checked
afterwards -- so a print whose assignments existed at print start, and were
gone by the time it finished, resolved its 3MF, resolved its grams,
resolved its tray, skipped the debit at INFO, and reported success. The
reporter lost 65.49 g that way and only noticed because a spool's remaining
weight looked wrong.
This fires on realized loss rather than risk: the trays passed here are the
ones a completed print actually tried to charge and could not. A print that
was already warned at start will notify twice, which is the right trade --
the first says the weight may not be tracked, the second says it was not.
Takes the caller's session: this runs inside ``on_print_complete``'s
transaction, and opening a second one to read the printer's name would
deadlock against it on SQLite.
"""
if not missing_global_trays:
return
try:
printer = await db.get(Printer, printer_id)
printer_name = printer.name if printer else f"Printer {printer_id}"
await _send_missing_assignment_notification(printer_id, printer_name, sorted(set(missing_global_trays)), db)
except Exception as e: # noqa: BLE001 — a notification must not fail a completed print
logger.warning("Missing spool-assignment completion notification failed: %s", e)
+24 -1
View File
@@ -1481,6 +1481,10 @@ async def _track_from_3mf(
pass # Fall back to linear scaling
results = []
# Trays this print drew from that no longer have an assignment to charge.
# Collected rather than acted on inline so one notification covers the whole
# print instead of one per slot (#2812).
unassigned_global_trays: list[int] = []
for usage in filament_usage:
slot_id = usage.get("slot_id", 0)
@@ -1707,7 +1711,19 @@ async def _track_from_3mf(
print_started_at=print_started_at,
)
if spool_id is None:
logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
# WARNING, not INFO: everything upstream of this line succeeded --
# the 3MF was found, the grams were read, the tray resolved -- and
# the print will still report success while this filament is never
# deducted. At INFO it was invisible under the default log level and
# absent from the reasoning in support bundles (#2812).
logger.warning(
"[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d — %.1fg not deducted",
printer_id,
ams_id,
tray_id,
used_g,
)
unassigned_global_trays.append(global_tray_id)
continue
# Load spool
@@ -1817,4 +1833,11 @@ async def _track_from_3mf(
)
archive.filament_type = joined_types
if unassigned_global_trays:
from backend.app.services.spool_assignment_notifications import (
notify_missing_spool_assignments_on_print_complete,
)
await notify_missing_spool_assignments_on_print_complete(printer_id, unassigned_global_trays, db, logger)
return results
@@ -0,0 +1,126 @@
"""Preserved built-in assignments must stay preserved (#2812).
The mode toggle no longer deletes them. That is only worth anything if the rest
of the app leaves them alone while Spoolman mode is active — otherwise they are
destroyed just as completely, only more slowly.
The auto-unlink pass in ``on_ams_change`` is the one that matters: it ends in
``db.delete`` for every assignment whose slot no longer matches the fingerprint
it recorded, which is precisely what happens once the user starts loading
different filament under the other mode.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
def _status(ams_data):
status = MagicMock()
status.raw_data = {"ams": ams_data, "vt_tray": []}
status.gcode_state = "IDLE"
return status
async def _run_ams_change(printer_id: int, ams_data: list):
from backend.app.main import on_ams_change
status = _status(ams_data)
with (
patch("backend.app.main.printer_manager") as pm_main,
patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
patch("backend.app.main.mqtt_relay") as relay,
patch("backend.app.main.ws_manager") as ws,
):
pm_main.get_printer.return_value = MagicMock(name="P", serial_number="SER")
pm_main.get_status.return_value = status
pm_main.get_client.return_value = MagicMock()
pm_main.get_model.return_value = "X1C"
pm_inv.get_status.return_value = status
pm_inv.get_client.return_value = MagicMock()
relay.on_ams_change = AsyncMock()
ws.send_printer_status = AsyncMock()
ws.broadcast = AsyncMock()
await on_ams_change(printer_id, ams_data)
class TestAutoUnlinkRespectsTheActiveMode:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spoolman_mode_does_not_unlink_built_in_assignments(
self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
):
"""The slot now holds something else entirely — a stale fingerprint by
every measure. In built-in mode that is a genuine unlink. In Spoolman
mode these rows are the user's preserved configuration, waiting for
them to switch back, and nothing here is entitled to delete them."""
printer = await printer_factory(name="P1S")
spool = Spool(material="PLA", rgba="FF0000FF")
db_session.add(spool)
await db_session.flush()
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(
SpoolAssignment(
spool_id=spool.id,
printer_id=printer.id,
ams_id=0,
tray_id=0,
fingerprint_color="FF0000FF",
fingerprint_type="PLA",
)
)
await db_session.commit()
await _run_ams_change(
printer.id,
[{"id": 0, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}],
)
rows = (
(await db_session.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer.id)))
.scalars()
.all()
)
assert len(rows) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_built_in_mode_still_unlinks_a_stale_assignment(
self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
):
"""The guard must not switch the feature off for the mode that owns it."""
printer = await printer_factory(name="P1S")
spool = Spool(material="PLA", rgba="FF0000FF")
db_session.add(spool)
await db_session.flush()
db_session.add(Settings(key="spoolman_enabled", value="false"))
db_session.add(
SpoolAssignment(
spool_id=spool.id,
printer_id=printer.id,
ams_id=0,
tray_id=0,
fingerprint_color="FF0000FF",
fingerprint_type="PLA",
)
)
await db_session.commit()
await _run_ams_change(
printer.id,
[{"id": 0, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}],
)
rows = (
(await db_session.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer.id)))
.scalars()
.all()
)
assert rows == []
@@ -2576,9 +2576,15 @@ class TestApplyPaAfterRefresh:
async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
"""No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
from backend.app.api.routes.printers import _apply_pa_after_refresh
from backend.app.models.settings import Settings
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory()
db_session.add(
SpoolmanSlotAssignment(
@@ -2622,8 +2628,14 @@ class TestApplyPaAfterRefresh:
async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
"""Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
from backend.app.api.routes.printers import _apply_pa_after_refresh
from backend.app.models.settings import Settings
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory()
db_session.add(
SpoolmanSlotAssignment(
@@ -3131,9 +3143,15 @@ class TestConfigureAmsSlotPersistsKProfile:
printer_factory,
):
"""SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
from backend.app.models.settings import Settings
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory(model="H2D")
db_session.add(
SpoolmanSlotAssignment(
@@ -3309,9 +3327,15 @@ class TestConfigureAmsSlotPersistsKProfile:
printer_factory,
):
"""cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
from backend.app.models.settings import Settings
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory(model="H2D")
db_session.add(
SpoolmanSlotAssignment(
@@ -3368,9 +3392,15 @@ class TestConfigureAmsSlotPersistsKProfile:
printer_factory,
):
"""cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
from backend.app.models.settings import Settings
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory(model="H2D")
db_session.add(
SpoolmanSlotAssignment(
@@ -3425,9 +3455,15 @@ class TestConfigureAmsSlotPersistsKProfile:
printer_factory,
):
"""Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
from backend.app.models.settings import Settings
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory(model="H2D")
db_session.add(
SpoolmanSlotAssignment(
@@ -3635,8 +3671,14 @@ class TestConfigureAmsSlotPersistsKProfile:
so we shouldn't return 500 to the user. The error is logged and the
endpoint returns success.
"""
from backend.app.models.settings import Settings
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
# A spoolman_slot_assignments row only exists in Spoolman mode, and
# since #2812 the mode is asked for explicitly rather than inferred
# from which table happens to hold rows.
db_session.add(Settings(key="spoolman_enabled", value="true"))
printer = await printer_factory(model="H2D")
db_session.add(
SpoolmanSlotAssignment(
@@ -579,20 +579,21 @@ class TestCascadeDeletePrinter:
assert post.scalars().all() == []
class TestModeSwitchClearsAssignments:
"""#1473 follow-up — the Spoolman mode toggle clears the other mode's
slot-assignment table so stale rows can't bleed across a mode switch."""
class TestModeSwitchKeepsAssignments:
"""#2812 — the mode toggle no longer deletes anything.
It used to empty the other mode's table on every switch, so opening the
settings page to see what Spoolman mode did destroyed the built-in slot
assignments, with no confirmation and no way back. Both tables are kept now
and the readers that could be confused by a row in the inactive one ask
which mode is active instead (``spoolman_owns_assignments``).
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_switch_to_internal_mode_clears_spoolman_slot_assignments(
async def test_switch_to_internal_mode_keeps_spoolman_slot_assignments(
self, async_client: AsyncClient, db_session, test_printer
):
"""Switching Spoolman OFF deletes spoolman_slot_assignments rows — the
symmetric counterpart of clearing legacy spool_assignment rows when
switching ON. Stale rows would otherwise wrongly count as 'assigned'
in mode-agnostic checks (e.g. the missing-spool-assignment notification,
which unions both tables)."""
from backend.app.models.settings import Settings
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
@@ -606,4 +607,30 @@ class TestModeSwitchClearsAssignments:
rows = await db_session.execute(
select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
)
assert rows.scalars().all() == []
assert len(rows.scalars().all()) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_switch_to_spoolman_mode_keeps_builtin_assignments(
self, async_client: AsyncClient, db_session, test_printer
):
"""The reported case: four toggles in 85 seconds took the reporter's
built-in assignments and never gave them back."""
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
db_session.add(Settings(key="spoolman_enabled", value="false"))
spool = Spool(material="PLA", rgba="000000FF")
db_session.add(spool)
await db_session.flush()
db_session.add(SpoolAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spool_id=spool.id))
await db_session.commit()
on = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "true"})
assert on.status_code == 200
off = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "false"})
assert off.status_code == 200
rows = await db_session.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == test_printer.id))
assert len(rows.scalars().all()) == 1
@@ -0,0 +1,235 @@
"""The inventory mode toggle is no longer destructive (#2812).
Turning Spoolman mode on ran an unfiltered ``delete(SpoolAssignment)`` across
every printer. Turning it straight back off cleared the *other* table instead,
so the built-in assignments were simply gone -- and the setting auto-saves on a
500 ms debounce with no confirmation, so inspecting the mode destroyed the
configuration. The reporter toggled four times in 85 seconds and never got
their assignments back.
The deletion had a real reason: checks that read both assignment tables would
otherwise let a row in the mode you are *not* using answer for the mode you
are. The fix is to make those checks ask which mode is active, which is where
that decision belongs, and then stop deleting.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
class _Result:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
def scalars(self):
return self
def first(self):
return self._value
def all(self):
return [self._value] if self._value is not None else []
class _TableRoutingSession:
"""Returns a row per model, so a test can populate either table or both."""
def __init__(self, rows: dict):
self._rows = rows
self.queried = []
async def execute(self, stmt):
entity = stmt.column_descriptions[0]["entity"]
name = entity.__name__
self.queried.append(name)
return _Result(self._rows.get(name))
class TestKProfileIgnoresTheInactiveModesTable:
"""slot_kprofile checked the built-in table first and, on a hit with no
matching profile, deliberately returned None rather than falling through to
Spoolman. That was safe only while the built-in table was guaranteed empty
in Spoolman mode. A leftover row would otherwise shadow the Spoolman
binding -- the symptom #1556 reported from the other direction.
"""
@pytest.mark.asyncio
async def test_spoolman_mode_does_not_read_the_built_in_table(self):
session = _TableRoutingSession(
{
# A leftover from before the user switched modes.
"SpoolAssignment": SimpleNamespace(spool_id=7),
"SpoolmanSlotAssignment": None,
}
)
with patch(
"backend.app.services.slot_kprofile.spoolman_owns_assignments",
new_callable=AsyncMock,
return_value=True,
):
result = await find_slot_kprofile_for_extruder(
session, printer_id=1, ams_id=0, tray_id=0, extruder=0, nozzle_diameter="0.4"
)
assert result is None
assert "SpoolAssignment" not in session.queried
@pytest.mark.asyncio
async def test_built_in_mode_does_not_read_the_spoolman_table(self):
session = _TableRoutingSession(
{
"SpoolAssignment": None,
"SpoolmanSlotAssignment": SimpleNamespace(spoolman_spool_id=9),
}
)
with patch(
"backend.app.services.slot_kprofile.spoolman_owns_assignments",
new_callable=AsyncMock,
return_value=False,
):
result = await find_slot_kprofile_for_extruder(
session, printer_id=1, ams_id=0, tray_id=0, extruder=0, nozzle_diameter="0.4"
)
assert result is None
assert "SpoolmanSlotAssignment" not in session.queried
class TestCostEstimateIgnoresTheInactiveModesTable:
"""A leftover built-in assignment must not price a pre-print estimate from
a spool the printer is not drawing on. The default rate is the honest
answer once the mode has moved on."""
@staticmethod
async def _estimate(spoolman_mode: bool):
from backend.app.services import print_cost_estimate as pce
library_file = SimpleNamespace(
file_path="nowhere/never.3mf",
file_metadata={"filament_used_grams": 100.0},
source_folder=None,
)
db = AsyncMock()
db.execute = AsyncMock(side_effect=AssertionError("the built-in table must not be queried"))
with (
patch(
"backend.app.services.print_cost_estimate.spoolman_owns_assignments",
new_callable=AsyncMock,
return_value=spoolman_mode,
),
patch(
"backend.app.services.print_cost_estimate._default_cost_per_kg",
new_callable=AsyncMock,
return_value=25.0,
),
patch(
"backend.app.services.print_cost_estimate._source_path",
return_value=__import__("pathlib").Path("/nonexistent/never.3mf"),
),
):
return await pce.estimate_queue_source_cost(
db,
library_file=library_file,
ams_mapping=[0],
printer_id=1,
)
@pytest.mark.asyncio
async def test_spoolman_mode_does_not_price_from_built_in_spools(self):
# 100 g at the 25/kg default. The AsyncMock would raise if the
# built-in assignment table were queried.
assert await self._estimate(spoolman_mode=True) == pytest.approx(2.5)
@pytest.mark.asyncio
async def test_built_in_mode_still_reads_its_own_table(self):
"""The guard must not switch the built-in path off as well."""
with pytest.raises(AssertionError, match="must not be queried"):
await self._estimate(spoolman_mode=False)
class TestCompletionNotifiesTheLostDebit:
"""The half that turned a mis-click into lost filament.
A print whose assignments existed at print start and were gone by the time
it finished resolved its 3MF, resolved its grams, resolved its tray, and
then skipped the debit because the row was missing -- at INFO, with no
notification, while the completion notification fired as usual. The
reporter's 65.49 g was never deducted and nothing surfaced it.
The print-start check cannot cover this: it runs before the job and was
correct to stay quiet, because at that moment the assignments existed.
"""
@pytest.mark.asyncio
async def test_a_skipped_debit_notifies_at_completion(self):
from backend.app.services.spool_assignment_notifications import (
notify_missing_spool_assignments_on_print_complete,
)
db = AsyncMock()
db.get = AsyncMock(return_value=SimpleNamespace(name="Printer A"))
logger = __import__("logging").getLogger(__name__)
with (
patch(
"backend.app.services.spool_assignment_notifications.printer_manager.get_status",
return_value=None,
),
patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_ws,
patch(
"backend.app.services.spool_assignment_notifications.notification_service."
"on_print_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_notify,
):
await notify_missing_spool_assignments_on_print_complete(1, [2], db, logger)
mock_ws.assert_awaited_once()
assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A3", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
@pytest.mark.asyncio
async def test_a_print_that_debited_everything_stays_quiet(self):
from backend.app.services.spool_assignment_notifications import (
notify_missing_spool_assignments_on_print_complete,
)
db = AsyncMock()
logger = __import__("logging").getLogger(__name__)
with patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_ws:
await notify_missing_spool_assignments_on_print_complete(1, [], db, logger)
mock_ws.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_failure_here_never_fails_the_completed_print(self):
"""The print is already done and its spools already written. A
notification that cannot be sent must not surface as a failed
completion."""
from backend.app.services.spool_assignment_notifications import (
notify_missing_spool_assignments_on_print_complete,
)
db = AsyncMock()
db.get = AsyncMock(side_effect=RuntimeError("db gone"))
logger = __import__("logging").getLogger(__name__)
await notify_missing_spool_assignments_on_print_complete(1, [2], db, logger)
@@ -89,9 +89,19 @@ async def test_missing_assignment_broadcasts_websocket_event_and_push_notificati
assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
def _patches(session):
"""Common patch set: the fake session + stubbed printer state / emitters."""
def _patches(session, spoolman_mode: bool = False):
"""Common patch set: the fake session + stubbed printer state / emitters.
``spoolman_mode`` decides which assignment table the check reads. Since
#2812 it reads one, not both: nothing empties the inactive table on a mode
toggle any more, so a leftover row there must not vouch for a tray.
"""
return (
patch(
"backend.app.services.spool_assignment_notifications.spoolman_owns_assignments",
new_callable=AsyncMock,
return_value=spoolman_mode,
),
patch(
"backend.app.services.spool_assignment_notifications.async_session",
return_value=session,
@@ -122,8 +132,8 @@ async def test_spoolman_only_assignment_suppresses_notification():
legacy=[],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
)
p_session, p_status, p_ws, p_notify = _patches(session)
with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=True)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_not_awaited()
@@ -142,8 +152,8 @@ async def test_spoolman_partial_coverage_flags_only_uncovered_tray():
legacy=[],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0)], # A1 only
)
p_session, p_status, p_ws, p_notify = _patches(session)
with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=True)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
@@ -152,19 +162,47 @@ async def test_spoolman_partial_coverage_flags_only_uncovered_tray():
@pytest.mark.asyncio
async def test_mixed_mode_union_covers_all_used_trays():
"""A1 bound in the legacy table, A2 bound in spoolman_slot_assignments —
the union covers both used trays, so no notification fires."""
async def test_a_row_in_the_inactive_modes_table_does_not_vouch_for_a_tray():
"""A1 bound in the legacy table, A2 bound in spoolman_slot_assignments.
This used to union both and stay quiet. That was safe only because the mode
toggle emptied whichever table the current mode was not using, so the two
could never both hold rows -- and that emptying is what destroyed people's
assignments for merely looking at the other mode (#2812). Nothing is
emptied now, so in Spoolman mode the legacy row for A1 is a leftover from
before the switch and says nothing about whether A1 is assigned today. A1
is exactly the tray this notification exists to flag.
"""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[SimpleNamespace(ams_id=0, tray_id=0)], # A1
spoolman=[SimpleNamespace(ams_id=0, tray_id=1)], # A2
legacy=[SimpleNamespace(ams_id=0, tray_id=0)], # A1 — leftover, not current
spoolman=[SimpleNamespace(ams_id=0, tray_id=1)], # A2 — the live binding
)
p_session, p_status, p_ws, p_notify = _patches(session)
with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=True)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A1", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
@pytest.mark.asyncio
async def test_built_in_mode_reads_the_built_in_table():
"""The mirror image: in built-in mode a Spoolman row is the leftover."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0)],
)
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=False)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_not_awaited()