fix(spoolman): per-print weight tracker falls back to local slot-assignment table for tag-less spools (#1459)

Reporter on Postgres + Spoolman saw weight never decremented after
  prints. Traced to _report_spool_usage_for_slots calling only
  client.find_spool_by_tag() — which returns None when extra.tag is empty.
  Non-RFID spools assigned via the Bambuddy UI intentionally leave
  extra.tag empty (per #1457 — we don't want fallback tags polluting
  Spoolman), so tag-less spools never got matched and weight tracking
  silently no-op'd. The tracker never consulted the local
  spoolman_slot_assignments table that has the binding.

  Adds _resolve_spool_id_via_slot_assignment() as stage 2 of the
  resolution chain. Stage 1 (existing tag-lookup) wins when present so
  RFID auto-sync remains unchanged. (ams_id, tray_id) derived from
  global_tray_id via the existing _global_tray_id_to_ams_slot helper,
  so external slots and AMS-HT slots resolve correctly. Threaded
  printer_id through the three callers (partial G-code, partial linear,
  final-usage report). Resolution path is logged ("via tag" vs "via
  slot-assignment") so support bundles confirm the fix is live.

  extra.tag is deliberately NOT auto-populated — that would re-introduce
  the exact pollution #1457 cleaned up. Slot-assignment table is the
  source of truth for non-RFID; extra.tag is reserved for hardware RFID.
This commit is contained in:
maziggy
2026-05-20 11:16:56 +02:00
parent 12b0c138f7
commit d3f0e9ac73
3 changed files with 266 additions and 13 deletions
+2
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Fixed
- **Spoolman: per-print weight reporting now works for tag-less spools assigned via the Bambuddy UI (#1459, reported by @Moskito99 — follow-up to #1119)** — Reporter on Postgres + Postgres-backed Spoolman noticed that prints finished cleanly but the spool's remaining weight in Spoolman was never decremented. He correctly traced it: Spoolman's `extra.tag` on his spool was empty, and writing a value in there by hand made weight tracking start working. **Root cause** is one missing fallback path. After #1119 introduced the local `spoolman_slot_assignments` table as the authoritative binding for tag-less spools (RFID is the binding for Bambu Lab spools, slot-assignment is the binding for generic / non-RFID spools), the Assign UI deliberately leaves Spoolman's `extra.tag` field empty for those spools — and after the #1457 cleanup we now actively *clear* it on re-binding to stop ghost links resurfacing in the hover card. That's the correct write-side behaviour. But the per-print weight tracker (`backend/app/services/spoolman_tracking.py:_report_spool_usage_for_slots`) only resolved the bound spool via `client.find_spool_by_tag(spool_tag)` — a single tag-lookup against Spoolman's `extra.tag`. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker **never consulted the local `spoolman_slot_assignments` table** that has the answer (verified: `grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.py` returned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (their `extra.tag` is auto-populated by the AMS-sync `create_spool` path at `backend/app/services/spoolman.py:1076`), and generic-spool users on Spoolman saw weight tracking silently no-op — exactly the symptom Moskito99 saw. **Fix** adds a two-stage resolver inside `_report_spool_usage_for_slots`: stage 1 is the existing `client.find_spool_by_tag(spool_tag)` (RFID and any RFID-equivalent `extra.tag` value), stage 2 is the new `_resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)` helper that queries the `SpoolmanSlotAssignment` table for `(printer_id, ams_id, tray_id) → spoolman_spool_id`. The (ams_id, tray_id) pair is derived from the slot's global_tray_id via the existing `_global_tray_id_to_ams_slot` helper — same translation used for fallback-tag generation, so external slots (global 254/255 → ams_id=255, tray_id=0/1) and AMS-HT slots (global 128+ → ams_id=global, tray_id=0) all resolve correctly. Stage-1-wins ordering is deliberate: when an RFID-bound spool is in the slot, `extra.tag` is the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting `[SPOOLMAN] … via tag` vs `… via slot-assignment` suffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live. `printer_id` threaded through the three callers (`_report_partial_usage` G-code path, `_report_partial_usage` linear path, `report_usage`) — they all already had `printer_id` in scope. Crucially, **`extra.tag` is NOT auto-populated** by this fix — that would re-introduce exactly the pollution #1457 cleaned up (deterministic fallback tags surviving across spool changes and surfacing stale spools in the hover card). The slot-assignment table is the source of truth for non-RFID bindings; Spoolman's `extra.tag` is reserved for hardware RFID identifiers. **Tests:** 5 new in `backend/tests/integration/test_spoolman_tracking_slot_fallback.py`: the bug repro (tag missing + slot-assignment present → use_spool by the slot-assignment's id); tag-match wins when both present (a regression that flips the resolution order would credit the wrong spool); skip-when-neither (no spool resolution attempted); skip-when-printer_id-not-supplied (legacy call shape stays inert); external-slot translation (global 254 → ams_id=255 tray_id=0 lookup works). New `patch_async_session` fixture routes the tracker's module-level `async_session` to the test engine so the in-test `SpoolmanSlotAssignment` insert is visible to the lookup. **Postgres compatibility:** verified — the lookup uses a plain `select(...where...).scalar_one_or_none()`, no SQLite-only syntax. 642 spoolman/tracking tests + 5 new = 647 green; full backend suite 5065 green; ruff clean.
- **Spoolman: AMS hover card and SpoolBuddy fill-bar no longer surface a stale spool after re-assigning a non-RFID slot (#1457, reported by @Menthe11)** — Reporter on a P1S with generic (non-RFID) PLA saw two different spools rendered in the AMS hover card: the top "Spulen-ID / Im Inventar öffnen" link pointed at an almost-empty black PLA spool that had been in the slot weeks earlier, while the bottom "Zugewiesen" block correctly showed the full spool the user had just assigned via Spoolman. **Root cause is two-layered.** For non-RFID slots Bambuddy falls back to a deterministic per-slot tag (`hash(printer_serial) + ams_id + tray_id`, 16 hex chars; see `frontend/src/utils/amsHelpers.ts:176`). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool's `extra.tag` — and the existing Link / Assign routes never cleared it from the previous holder when the user re-bound the slot to a different spool. The frontend's hover-card resolver at `frontend/src/pages/PrintersPage.tsx:3736` (and the matching sites at `:4137` / `:4452` for HT and external slots) then preferred that stale tag-link over the user's explicit slot-assignment: `linkedSpoolId: (trayTag ? linkedSpools?.[trayTag]?.id : undefined) ?? slotAssignmentForFill?.spoolman_spool_id`. So when both layers existed and they disagreed, the *stale* spool won, and FilamentHoverCard's dedupe at line 377 couldn't collapse the two buttons because the IDs didn't match → two "Im Inventar öffnen" buttons pointing at different spools. The SpoolBuddy AMS page had the identical bug shape in two more spots: `getSpoolmanFillForSlot()` (the per-slot fill-percentage resolver, line 138) walked tag-link before slot-assignment, so the fill bar reported the *old* spool's remaining grams instead of the freshly assigned full one; and the slot-action picker's "Linked spool" / "Assigned spool" branches (line 760) showed "Linked spool" whenever a tag-link existed, regardless of whether a (more recent) slot-assignment also existed. **Fix has two parts.** (1) Frontend precedence swap at all five sites: slot-assignment is the user's most explicit, most recent action — it must outrank the tag-link, which is auto-populated and can be silently stale. With the swap, FilamentHoverCard's existing match-dedupe collapses both buttons into one pointing at the correct spool; SpoolBuddy's fill bar reads from the assigned spool's weight first; and SpoolBuddy's slot-action picker drops the stale "Linked spool" line entirely when a slot-assignment exists. (2) Backend hygiene so the stale state is never written in the first place: a new `_clear_stale_tag_links(client, tag, keep_spool_id, log_context)` helper in `backend/app/api/routes/spoolman_inventory.py` enumerates Spoolman spools and PATCHes `extra.tag` to JSON-empty (`'""'`, the same wire shape `unlink_spool` already uses so the read-side `.strip('"')` filter in `get_linked_spools` skips it) on any spool *other than* the one being bound that still claims the same tag. Wired into `POST /spoolman/inventory/slot-assignments` (computes the slot's deterministic fallback tag via the existing `get_fallback_spool_tag_for_slot` helper in `spoolman_tracking.py` — newly promoted to a public symbol that mirrors the frontend's `getFallbackSpoolTag(serial, amsId, trayId)` signature) and `POST /spoolman/spools/{id}/link` (passes the literal `spool_tag` being bound — works for both RFID tags and fallback tags). Both are best-effort: per-spool patch failures and Spoolman enumeration failures are logged and skipped, never raised, so the assign/link path never wedges on a Spoolman hiccup. Existing assign-route tests stay green because their fixtures' Spoolman client mock already had `get_spools` returning `[]` (or now does — fixture updated in `test_spoolman_slot_assignments.py`, `test_spoolman_slot_concurrency.py`, `test_spoolman_slot_assignment_mqtt.py`, and the link-route test fixture in `test_spoolman_api.py`). **Tests** (8 new in `backend/tests/unit/test_spoolman_stale_tag_cleanup.py`): clears one other-spool while keeping the bound spool and unrelated-tag spool intact; case-insensitive match (the helper uppercases both sides because `get_linked_spools` already does); empty-tag short-circuits without enumerating spools; `keep_spool_id` guards against clearing the spool being bound; Spoolman 5xx during enumeration is swallowed and the call returns 0; one per-spool patch failure doesn't abort the rest of the cleanup; the slot-fallback wrapper computes the right tag and clears it; empty serial returns 0 without enumerating. Backend: ruff clean, 581 spoolman tests + 8 new = 589 green. Frontend build clean.
- **AMS drying popover no longer renders off the bottom of the viewport + diagnostic logging for the silent-drying-ignore bug (#1447, reported by @kleinweby)** — Two distinct bugs in the same report, both shipped in this PR. **(1) Popover positioning**: reporter on P1S + AMS-HT couldn't see the Start button on the drying popover and worked around it via DevTools to confirm the popover was actually there, just clipped below the fold. Root cause in `frontend/src/pages/PrintersPage.tsx:3498 / :4011` (two identical sites — one for the compact AMS row, one for the dual-nozzle layout): the flame-icon onClick computed popover position as a fixed `{ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }` with no viewport-overflow check. The flame icon sits at the bottom of the AMS info section on the printer card, so on most realistic viewports `rect.bottom + 4 + popover_height(~320px) > viewport.height` and the popover rendered partially or entirely off-screen. Fix extracts a `computePopoverPosition()` helper in `frontend/src/utils/popoverPosition.ts` that defaults to placing the popover below + right-aligned to the trigger (preserving the original visual layout), flips ABOVE the trigger when below would overflow AND above would fit, stays below in the degraded case where neither fits (popover taller than viewport — at least the top is visible and the user can scroll inside), and clamps the left coordinate so a trigger near either viewport edge can't push the popover off-screen horizontally either. Both PrintersPage callsites now go through the helper. **(2) Diagnostic logging for the silent-drying-ignore**: reporter's support bundle showed the printer receives every `ams_filament_drying` command (multiple start / stop attempts on `ams_id=128`, P1S 01.10.00.00 firmware), the printer ACKs each one, but the AMS info field never changes — drying neither starts nor stops on Bambuddy's request, while pressing Start on the printer's touchscreen worked immediately (so the hardware path is healthy and the LAN MQTT channel is delivering). The Bambuddy command JSON matches the format documented as working on H2D, all required fields are present, types match BambuStudio. Diagnosing the silent rejection needs the printer's actual response payload — whether `result: "fail"` and the specific `reason` code — but `bambu_mqtt.py:918` was only logging the response *command name*, not the body. The existing `extrusion_cali_*` / `ams_filament_setting` debug path at `:919-920` was the template; this PR extends it to `ams_filament_drying` at **INFO level** specifically (not DEBUG like its siblings) because drying responses are rare — user-initiated only — and INFO ensures the body lands in support bundles by default without needing the user to bump log level first. Paired with a matching outgoing-side INFO log inside `send_drying_command` that captures the full wire JSON, so the next support bundle has **both halves of the conversation**. The actual command-side fix can't happen without that data (no guessing — flipping `close_power_conflict: true` or otherwise mutating a field that matches the documented-working H2D shape could break currently-working installs). When kleinweby retries on this build and re-attaches a bundle, the rejection reason is visible and the command-side fix follows from real data. **Tests** (8 new in `__tests__/utils/popoverPosition.test.ts`): below-has-room places below; right-align to trigger; below overflows flips above; degraded case stays below; clamps right-edge and left-edge triggers; respects custom margin and gap. 276 backend service tests + frontend build clean.
+82 -13
View File
@@ -355,6 +355,28 @@ async def _get_spoolman_client_with_fallback():
return client
async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tray_id: int) -> int | None:
"""Look up the Spoolman spool ID locally bound to (printer, ams, tray).
Fallback path for #1459: when a tag-less spool was assigned via the
Bambuddy UI, the user's deterministic fallback tag is intentionally NOT
written to Spoolman's extra.tag (kept clean per #1457), so
find_spool_by_tag misses. The local spoolman_slot_assignments table is
the authoritative binding for those spools.
"""
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
async with async_session() as db:
result = await db.execute(
select(SpoolmanSlotAssignment.spoolman_spool_id).where(
SpoolmanSlotAssignment.printer_id == printer_id,
SpoolmanSlotAssignment.ams_id == ams_id,
SpoolmanSlotAssignment.tray_id == tray_id,
)
)
return result.scalar_one_or_none()
async def _report_spool_usage_for_slots(
client,
filament_usage_items: list[tuple[int, float]],
@@ -362,9 +384,17 @@ async def _report_spool_usage_for_slots(
slot_to_tray: list | None,
method_label: str,
printer_serial: str = "",
printer_id: int | None = None,
) -> int:
"""Report usage to Spoolman for a list of (slot_id, grams) pairs.
Resolution order per slot: (1) Spoolman extra.tag match against the
tray's RFID or deterministic fallback tag, (2) #1459 fallback —
local spoolman_slot_assignments table keyed by (printer_id, ams_id,
tray_id). Without (2), tag-less spools assigned via the Bambuddy UI
never get their weight decremented because their extra.tag is empty
on the Spoolman side.
Returns number of spools successfully updated.
"""
spools_updated = 0
@@ -388,22 +418,43 @@ async def _report_spool_usage_for_slots(
is_external,
)
spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
if not spool_tag:
logger.debug("[SPOOLMAN] Slot %s: no identifier for tray %s", slot_id, global_tray_id)
continue
spool_id_to_use: int | None = None
resolution_path = ""
spool = await client.find_spool_by_tag(spool_tag)
if not spool:
logger.debug("[SPOOLMAN] Slot %s: no spool for tag %s...", slot_id, spool_tag[:16])
spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
if spool_tag:
spool = await client.find_spool_by_tag(spool_tag)
if spool:
spool_id_to_use = spool["id"]
resolution_path = "tag"
if spool_id_to_use is None and printer_id is not None:
ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
if spool_id_to_use is not None:
resolution_path = "slot-assignment"
if spool_id_to_use is None:
logger.debug(
"[SPOOLMAN] Slot %s: no spool resolved (tag=%s, no slot-assignment)",
slot_id,
spool_tag[:16] if spool_tag else "none",
)
continue
try:
await client.use_spool(spool["id"], grams_used)
logger.info("[SPOOLMAN] %s: slot %s: %sg -> spool %s", method_label, slot_id, grams_used, spool["id"])
await client.use_spool(spool_id_to_use, grams_used)
logger.info(
"[SPOOLMAN] %s: slot %s: %sg -> spool %s (via %s)",
method_label,
slot_id,
grams_used,
spool_id_to_use,
resolution_path,
)
spools_updated += 1
except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool["id"], exc)
logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
return spools_updated
@@ -526,7 +577,13 @@ async def _report_partial_usage(
usage_items.append((slot_id, grams_used))
spools_updated = await _report_spool_usage_for_slots(
client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)", printer_serial
client,
usage_items,
ams_trays,
slot_to_tray,
"Partial (G-code)",
printer_serial,
printer_id=printer_id,
)
if spools_updated > 0:
logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
@@ -558,7 +615,13 @@ async def _report_partial_usage(
usage_items.append((slot_id, partial_used_g))
spools_updated = await _report_spool_usage_for_slots(
client, usage_items, ams_trays, slot_to_tray, "Partial (linear)", printer_serial
client,
usage_items,
ams_trays,
slot_to_tray,
"Partial (linear)",
printer_serial,
printer_id=printer_id,
)
if spools_updated > 0:
logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
@@ -613,7 +676,13 @@ async def report_usage(printer_id: int, archive_id: int):
usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
spools_updated = await _report_spool_usage_for_slots(
client, usage_items, ams_trays, slot_to_tray, f"Archive {archive_id}", printer_serial
client,
usage_items,
ams_trays,
slot_to_tray,
f"Archive {archive_id}",
printer_serial,
printer_id=printer_id,
)
if spools_updated == 0:
@@ -0,0 +1,182 @@
"""Integration tests for #1459 — per-print weight tracker falls back to the
local spoolman_slot_assignments table when Spoolman's extra.tag is empty.
Without this, tag-less spools assigned via the Bambuddy UI never get their
weight decremented because the Assign route intentionally leaves extra.tag
unset (per #1457 — fallback tags must not pollute Spoolman).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.services.spoolman_tracking import _report_spool_usage_for_slots
@pytest.fixture
def mock_spoolman_client():
client = MagicMock()
# Default: every tag-lookup returns None (the bug case — no extra.tag on Spoolman side).
client.find_spool_by_tag = AsyncMock(return_value=None)
client.use_spool = AsyncMock(return_value={"id": 0})
return client
@pytest.fixture
def patch_async_session(test_engine):
"""Route the tracker's async_session() to the test engine so the slot-assignment
fallback lookup sees rows committed via db_session in the same test."""
test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
with patch("backend.app.services.spoolman_tracking.async_session", test_async_session):
yield
@pytest.fixture
async def test_printer(db_session):
from backend.app.models.printer import Printer
printer = Printer(
name="Tracking Test",
serial_number="TRACKTEST123456",
ip_address="192.168.0.99",
access_code="12345678",
model="P1S",
is_active=True,
auto_archive=True,
)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.usefixtures("patch_async_session")
class TestSlotAssignmentFallback:
async def test_falls_back_to_slot_assignment_when_tag_missing(self, test_printer, mock_spoolman_client, db_session):
"""Tag-less spool assigned via Bambuddy UI: extra.tag is empty (find_spool_by_tag
returns None) but the local spoolman_slot_assignments row says spool 42 lives in
AMS 0 tray 2 — the tracker must still report usage to spool 42.
slot_id is 1-based; ams_trays is keyed by global_tray_id. For AMS 0 tray 2,
global_tray_id = 2, so we hand the tracker slot_id=3 (since slot_id-1=global=2).
"""
db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=2, spoolman_spool_id=42))
await db_session.commit()
ams_trays = {2: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
usage_items = [(3, 15.5)]
spools_updated = await _report_spool_usage_for_slots(
mock_spoolman_client,
usage_items,
ams_trays,
slot_to_tray=None,
method_label="Test",
printer_serial=test_printer.serial_number,
printer_id=test_printer.id,
)
assert spools_updated == 1
mock_spoolman_client.use_spool.assert_awaited_once_with(42, 15.5)
async def test_tag_match_wins_over_slot_assignment(self, test_printer, mock_spoolman_client, db_session):
"""When both paths could resolve a spool, the tag-match wins — RFID is the
authoritative binding when present. Order matters so RFID auto-sync continues
to bind to the spool whose extra.tag literally holds that RFID, even if the
slot-assignment table happens to point at a different spool."""
db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=999))
await db_session.commit()
mock_spoolman_client.find_spool_by_tag = AsyncMock(return_value={"id": 7})
ams_trays = {0: {"tray_uuid": "A" * 32, "tag_uid": "", "tray_type": "PLA"}}
# slot_id=1 → global_tray_id=0 (AMS 0 tray 0).
usage_items = [(1, 10.0)]
spools_updated = await _report_spool_usage_for_slots(
mock_spoolman_client,
usage_items,
ams_trays,
slot_to_tray=None,
method_label="Test",
printer_serial=test_printer.serial_number,
printer_id=test_printer.id,
)
assert spools_updated == 1
mock_spoolman_client.use_spool.assert_awaited_once_with(7, 10.0)
async def test_skips_when_neither_path_resolves(self, test_printer, mock_spoolman_client, db_session):
"""No tag in Spoolman AND no slot-assignment row → tracker skips the slot
rather than crashing or reporting against the wrong spool."""
ams_trays = {0: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
# slot_id=1 → global_tray_id=0 (AMS 0 tray 0); no assignment row exists.
usage_items = [(1, 5.0)]
spools_updated = await _report_spool_usage_for_slots(
mock_spoolman_client,
usage_items,
ams_trays,
slot_to_tray=None,
method_label="Test",
printer_serial=test_printer.serial_number,
printer_id=test_printer.id,
)
assert spools_updated == 0
mock_spoolman_client.use_spool.assert_not_called()
async def test_skips_when_printer_id_not_supplied(self, test_printer, mock_spoolman_client, db_session):
"""Slot-assignment fallback requires printer_id to look up the binding —
when callers don't supply it (legacy call shape) the lookup is skipped
and the slot is reported as unresolved, matching pre-#1459 behaviour for
those callers."""
db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
await db_session.commit()
ams_trays = {0: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
usage_items = [(1, 5.0)]
spools_updated = await _report_spool_usage_for_slots(
mock_spoolman_client,
usage_items,
ams_trays,
slot_to_tray=None,
method_label="Test",
printer_serial=test_printer.serial_number,
# printer_id omitted on purpose
)
assert spools_updated == 0
mock_spoolman_client.use_spool.assert_not_called()
async def test_external_slot_falls_back_via_correct_ams_tray_pair(
self, test_printer, mock_spoolman_client, db_session
):
"""External spool slots use global_tray_id 254/255 which map to ams_id=255,
tray_id=0/1. The slot-assignment lookup must use that translated pair, not the
raw global id, otherwise the row is never found."""
db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=255, tray_id=0, spoolman_spool_id=88))
await db_session.commit()
# Position-based default with ams_trays={254: ...}: sorted_tray_ids=[254],
# slot_id=1 → sorted_tray_ids[0] = 254 (global) → ams_id=255 tray_id=0.
ams_trays = {254: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
usage_items = [(1, 25.0)]
spools_updated = await _report_spool_usage_for_slots(
mock_spoolman_client,
usage_items,
ams_trays,
slot_to_tray=None,
method_label="Test",
printer_serial=test_printer.serial_number,
printer_id=test_printer.id,
)
assert spools_updated == 1
mock_spoolman_client.use_spool.assert_awaited_once_with(88, 25.0)