fix(ams): keep slot card preset name in sync after spool swaps

The slot card on PrintersPage shows slot_preset_mappings.preset_name
  first in its display fallback chain. Three write paths swap which
  spool occupies a given slot:

  - internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
  - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
  - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)

  Only the first one was reconciling the row. After an RFID-driven
  spool change, the card kept surfacing the previous spool's preset
  name until the user opened Configure Slot manually.

  Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a
  freshly-inserted Bambu PLA-CF spool. The matching row in
  slot_preset_mappings was last written in March when a PLA Silk+
  spool had been in that slot - confirmed live in the database.

  New backend/app/services/slot_preset_writer.py exposes a primitive
  upsert_slot_preset plus two derivation wrappers: one for the
  internal Spool ORM object, one for the Spoolman API dict shape.
  All three call sites now go through the helper, so the row stays
  in lockstep with the assigned spool regardless of inventory mode.

  Bug shape exists in both inventory modes and the patch fixes both
  per feedback_inventory_modes_parity. The Spoolman path was latent
  for users who'd never manually picked a slot preset; the same
  "stale row overrides correct catalog name" symptom appeared for
  those who had.

  Existing stale rows self-heal on the next RFID-driven swap.
This commit is contained in:
maziggy
2026-06-10 08:39:21 +02:00
parent 60b253f98a
commit 0793022818
7 changed files with 594 additions and 41 deletions
+16 -41
View File
@@ -394,48 +394,23 @@ async def apply_spool_to_slot_via_mqtt(
)
# Persist slot preset mapping for UI display (preset_name on hover card).
try:
from backend.app.models.slot_preset import SlotPresetMapping
# Shared with the RFID auto-assign path — both must keep this row in sync
# with the currently-assigned spool, otherwise the slot card surfaces the
# previous spool's preset name (the PrintersPage display chain consults
# slot_preset_mappings.preset_name first).
from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
preset_source = "cloud"
if sf:
base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
try:
int(base_sf_mapping)
preset_id_to_save = f"local_{base_sf_mapping}"
preset_source = "local"
except (ValueError, TypeError):
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
else:
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
if preset_id_to_save:
existing_mapping = await db.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer_id,
SlotPresetMapping.ams_id == ams_id,
SlotPresetMapping.tray_id == tray_id,
)
)
mapping = existing_mapping.scalar_one_or_none()
if mapping:
mapping.preset_id = preset_id_to_save
mapping.preset_name = preset_name
mapping.preset_source = preset_source
else:
mapping = SlotPresetMapping(
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
preset_id=preset_id_to_save,
preset_name=preset_name,
preset_source=preset_source,
)
db.add(mapping)
await db.commit()
except Exception as e:
logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
await upsert_slot_preset_for_spool(
db=db,
spool=spool,
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=tray_info_idx,
tray_sub_brands=tray_sub_brands,
tray_type=tray_type,
setting_id=setting_id,
)
logger.info(
"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
+18
View File
@@ -1695,6 +1695,24 @@ async def on_ams_change(printer_id: int, ams_data: list):
printer_id,
result["id"],
)
# Reconcile slot_preset_mappings (the same row internal
# mode keeps in sync via inventory + spool_tag_matcher).
# Without this the slot card surfaces the previous spool's
# preset name — same bug shape, different inventory mode.
from backend.app.services.slot_preset_writer import (
upsert_slot_preset_for_spoolman_spool,
)
await upsert_slot_preset_for_spoolman_spool(
db=db,
spoolman_spool=result,
tray_info_idx=tray.tray_info_idx or "",
tray_sub_brands=tray.tray_sub_brands or "",
tray_type=tray.tray_type or "",
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray.tray_id,
)
except Exception as e:
logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
+158
View File
@@ -0,0 +1,158 @@
"""Shared upsert for the slot_preset_mappings row that drives the AMS slot
card's displayed preset name.
Three call sites must keep this row in sync with the currently-assigned spool:
- ``api.routes.inventory.apply_spool_to_slot_via_mqtt`` (internal manual assign)
- ``services.spool_tag_matcher.auto_assign_spool`` (internal RFID auto-assign)
- ``main.auto_sync_spoolman_ams_trays`` (Spoolman RFID-driven sync)
If any of them skips this row, the slot card surfaces the previous spool's
preset name because the PrintersPage display chain consults
slot_preset_mappings.preset_name first — it overrides cloudInfo.name and the
spool's own slicer_filament_name.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.slot_preset import SlotPresetMapping
from backend.app.models.spool import Spool
from backend.app.utils.filament_ids import filament_id_to_setting_id
logger = logging.getLogger(__name__)
async def upsert_slot_preset(
*,
db: AsyncSession,
printer_id: int,
ams_id: int,
tray_id: int,
preset_id: str,
preset_name: str,
preset_source: str = "cloud",
) -> None:
"""Primitive upsert. No-op when ``preset_id`` is empty (the column is
NOT NULL on the model, and an empty string isn't a useful key to
overwrite by). Soft-fails on DB errors so a broken upsert never
cascades into the surrounding spool-assign flow.
"""
if not preset_id:
return
try:
existing = await db.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer_id,
SlotPresetMapping.ams_id == ams_id,
SlotPresetMapping.tray_id == tray_id,
)
)
mapping = existing.scalar_one_or_none()
if mapping:
mapping.preset_id = preset_id
mapping.preset_name = preset_name
mapping.preset_source = preset_source
else:
mapping = SlotPresetMapping(
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
preset_id=preset_id,
preset_name=preset_name,
preset_source=preset_source,
)
db.add(mapping)
await db.commit()
except Exception as e:
logger.warning(
"Failed to save slot preset mapping for printer=%d ams=%d tray=%d: %s",
printer_id,
ams_id,
tray_id,
e,
)
async def upsert_slot_preset_for_spool(
*,
db: AsyncSession,
spool: Spool,
printer_id: int,
ams_id: int,
tray_id: int,
tray_info_idx: str = "",
tray_sub_brands: str = "",
tray_type: str = "",
setting_id: str = "",
) -> None:
"""Convenience wrapper for internal-mode call sites — derives the
(preset_id, preset_name, preset_source) triple from a ``Spool`` ORM object,
then defers to ``upsert_slot_preset``.
Local numeric ``spool.slicer_filament`` (e.g. ``"50"``) → ``local_50``;
cloud-form ids (GFS… / GFA… via ``filament_id_to_setting_id`` on the
tray's ``tray_info_idx``) → standard setting_id form.
"""
preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
preset_source = "cloud"
sf = spool.slicer_filament or ""
if sf:
base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
try:
int(base_sf_mapping)
preset_id = f"local_{base_sf_mapping}"
preset_source = "local"
except (ValueError, TypeError):
preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
else:
preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
await upsert_slot_preset(
db=db,
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
preset_id=preset_id,
preset_name=preset_name,
preset_source=preset_source,
)
async def upsert_slot_preset_for_spoolman_spool(
*,
db: AsyncSession,
spoolman_spool: dict,
tray_info_idx: str,
tray_sub_brands: str,
tray_type: str,
printer_id: int,
ams_id: int,
tray_id: int,
) -> None:
"""Spoolman-mode counterpart. Derives ``preset_name`` from the Spoolman
filament's display name (falling back to material if absent) and
``preset_id`` from the AMS-reported tray_info_idx (the cloud filament
id the printer is currently using). ``preset_source`` is always
``"cloud"`` since Spoolman doesn't carry a local-preset concept.
The ``spoolman_spool`` dict is the shape returned by
``SpoolmanClient.sync_ams_tray`` — ``spool["filament"]["name"]`` etc.
"""
filament = spoolman_spool.get("filament") or {}
preset_name = filament.get("name") or filament.get("material") or tray_sub_brands or tray_type
preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
await upsert_slot_preset(
db=db,
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
preset_id=preset_id,
preset_name=preset_name or "",
preset_source="cloud",
)
+17
View File
@@ -578,4 +578,21 @@ async def auto_assign_spool(
except Exception as e:
logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
# Reconcile slot_preset_mappings so the AMS slot card stops surfacing the
# previous spool's preset name. Shared with the manual-assign path
# (inventory.apply_spool_to_slot_via_mqtt). Outside the try above so a
# transient MQTT failure doesn't leave the display row stale.
from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
await upsert_slot_preset_for_spool(
db=db,
spool=spool,
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=tray_info_idx,
tray_sub_brands=tray.get("tray_sub_brands", "") if tray else "",
tray_type=tray.get("tray_type", "") if tray else "",
)
return assignment
@@ -0,0 +1,201 @@
"""Tests for the shared slot_preset_mappings writer.
The writer keeps PrintersPage's slot-card preset_name in sync with the
currently-assigned spool from three different inventory paths:
* internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
* internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
* Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)
Internal-mode regression coverage lives in test_spool_tag_matcher.py
(which exercises the call site end-to-end). This file focuses on the
helper-level contracts — local-preset id formatting, soft-fail on
no preset_id, and the Spoolman derivation path.
"""
import pytest
from sqlalchemy import select
from backend.app.models.slot_preset import SlotPresetMapping
from backend.app.services.slot_preset_writer import (
upsert_slot_preset,
upsert_slot_preset_for_spoolman_spool,
)
@pytest.mark.asyncio
async def test_upsert_no_op_when_preset_id_empty(db_session, printer_factory):
"""An empty preset_id is not a useful key — the model's column is NOT
NULL and an empty string would overwrite the user's last good preset
with garbage. Skip without raising."""
printer = await printer_factory()
await upsert_slot_preset(
db=db_session,
printer_id=printer.id,
ams_id=0,
tray_id=0,
preset_id="",
preset_name="ignored",
)
result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_upsert_inserts_then_updates(db_session, printer_factory):
"""First call inserts, second call on same (printer, ams, tray) updates
in place rather than violating the unique constraint."""
printer = await printer_factory()
await upsert_slot_preset(
db=db_session,
printer_id=printer.id,
ams_id=0,
tray_id=1,
preset_id="GFSA50",
preset_name="Bambu PLA-CF",
preset_source="cloud",
)
await upsert_slot_preset(
db=db_session,
printer_id=printer.id,
ams_id=0,
tray_id=1,
preset_id="GFSA00",
preset_name="Bambu PLA Basic",
preset_source="cloud",
)
rows = (
(await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id)))
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].preset_id == "GFSA00"
assert rows[0].preset_name == "Bambu PLA Basic"
# -- Spoolman derivation ----------------------------------------------------
@pytest.mark.asyncio
async def test_spoolman_helper_uses_filament_name_and_tray_info_idx(db_session, printer_factory):
"""A Spoolman spool with a typical filament shape — name + material —
should land in the row with the AMS-reported tray_info_idx as
preset_id (normalised to setting_id form) and the filament name as
preset_name."""
printer = await printer_factory()
spoolman_spool = {
"id": 42,
"filament": {
"id": 7,
"name": "Bambu PLA-CF Burgundy Red",
"material": "PLA-CF",
},
}
await upsert_slot_preset_for_spoolman_spool(
db=db_session,
spoolman_spool=spoolman_spool,
tray_info_idx="GFA50",
tray_sub_brands="PLA-CF",
tray_type="PLA",
printer_id=printer.id,
ams_id=1,
tray_id=2,
)
mapping = (
await db_session.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer.id,
SlotPresetMapping.ams_id == 1,
SlotPresetMapping.tray_id == 2,
)
)
).scalar_one()
assert mapping.preset_id == "GFSA50"
assert mapping.preset_name == "Bambu PLA-CF Burgundy Red"
assert mapping.preset_source == "cloud"
@pytest.mark.asyncio
async def test_spoolman_helper_falls_back_to_material_when_name_missing(db_session, printer_factory):
"""Some Spoolman setups have unnamed filaments — fall back through
material → tray_sub_brands → tray_type so we never write an empty
preset_name."""
printer = await printer_factory()
spoolman_spool = {"id": 99, "filament": {"material": "PETG"}}
await upsert_slot_preset_for_spoolman_spool(
db=db_session,
spoolman_spool=spoolman_spool,
tray_info_idx="GFG00",
tray_sub_brands="PETG Basic",
tray_type="PETG",
printer_id=printer.id,
ams_id=0,
tray_id=0,
)
mapping = (
await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
).scalar_one()
assert mapping.preset_name == "PETG"
@pytest.mark.asyncio
async def test_spoolman_helper_overwrites_stale_internal_row(db_session, printer_factory):
"""Mirror of the internal-mode regression: pre-seed a stale row with a
previous spool's name, run the Spoolman helper, verify the row now
reflects the freshly-synced Spoolman spool. This is the bug shape
that would surface on a Spoolman user with a manually-set preset."""
printer = await printer_factory()
db_session.add(
SlotPresetMapping(
printer_id=printer.id,
ams_id=1,
tray_id=2,
preset_id="GFSA06_09",
preset_name="Bambu PLA Silk+",
preset_source="cloud",
)
)
await db_session.commit()
await upsert_slot_preset_for_spoolman_spool(
db=db_session,
spoolman_spool={"id": 42, "filament": {"name": "Bambu PLA-CF"}},
tray_info_idx="GFA50",
tray_sub_brands="PLA-CF",
tray_type="PLA",
printer_id=printer.id,
ams_id=1,
tray_id=2,
)
mapping = (
await db_session.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer.id,
SlotPresetMapping.ams_id == 1,
SlotPresetMapping.tray_id == 2,
)
)
).scalar_one()
assert mapping.preset_name == "Bambu PLA-CF"
assert mapping.preset_id == "GFSA50"
@pytest.mark.asyncio
async def test_spoolman_helper_skips_when_tray_info_idx_unknown(db_session, printer_factory):
"""No tray_info_idx → no preset_id → upsert skips. Caller must not
write an empty-string preset_id (would clobber any existing row)."""
printer = await printer_factory()
await upsert_slot_preset_for_spoolman_spool(
db=db_session,
spoolman_spool={"id": 1, "filament": {"name": "Random"}},
tray_info_idx="",
tray_sub_brands="",
tray_type="PLA",
printer_id=printer.id,
ams_id=0,
tray_id=0,
)
result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
assert result.scalar_one_or_none() is None
@@ -1365,3 +1365,185 @@ async def test_auto_assign_kprofile_takes_priority_over_live_cali_idx(db_session
mqtt_mock.extrusion_cali_sel.assert_called_once()
call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
assert call_kwargs["cali_idx"] == 7 # stored profile, not 99
# -- slot_preset_mappings reconciliation on RFID auto-assign ----------------
#
# The slot card on PrintersPage shows slot_preset_mappings.preset_name first
# in its fallback chain (it's the user-configured override for a slot). When a
# new spool gets auto-assigned via RFID the manual-assign path used to be the
# only one that kept this row in sync, so the slot card kept showing the
# previous spool's preset name until the user opened Configure Slot manually.
@pytest.mark.asyncio
async def test_auto_assign_overwrites_stale_slot_preset_mapping(db_session, printer_factory):
"""Pre-seed a slot_preset_mappings row from a previous spool, run RFID
auto-assign with a different filament, and verify the row reflects the
NEW spool's preset (not the stale one). The bug being pinned: the user's
AMS-B3 (PLA-CF) kept showing 'Bambu PLA Silk+' because the row was last
written when the PLA Silk+ spool was loaded back in March.
"""
from unittest.mock import MagicMock
from sqlalchemy import select as sa_select
from backend.app.models.slot_preset import SlotPresetMapping
printer = await printer_factory()
db_session.add(
SlotPresetMapping(
printer_id=printer.id,
ams_id=1,
tray_id=2,
preset_id="GFSA06_09",
preset_name="Bambu PLA Silk+",
preset_source="cloud",
)
)
await db_session.commit()
spool = Spool(
material="PLA-CF",
subtype="CF",
brand="Bambu Lab",
label_weight=1000,
core_weight=250,
slicer_filament="GFA50",
slicer_filament_name="Bambu PLA-CF",
rgba="951E23FF",
)
spool.k_profiles = []
spool.assignments = []
db_session.add(spool)
await db_session.flush()
mock_pm = MagicMock()
mock_pm.get_status.return_value = None
mock_pm.get_client.return_value = None
await auto_assign_spool(
printer_id=printer.id,
ams_id=1,
tray_id=2,
spool=spool,
printer_manager=mock_pm,
db=db_session,
tray_info_idx="GFA50",
)
await db_session.commit()
result = await db_session.execute(
sa_select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer.id,
SlotPresetMapping.ams_id == 1,
SlotPresetMapping.tray_id == 2,
)
)
mapping = result.scalar_one()
assert mapping.preset_name == "Bambu PLA-CF"
assert mapping.preset_id == "GFSA50"
assert mapping.preset_source == "cloud"
@pytest.mark.asyncio
async def test_auto_assign_inserts_slot_preset_when_absent(db_session, printer_factory):
"""No pre-existing row → auto-assign inserts one. Pairs with the upsert
case to keep both branches of the helper covered from this path."""
from unittest.mock import MagicMock
from sqlalchemy import select as sa_select
from backend.app.models.slot_preset import SlotPresetMapping
printer = await printer_factory()
spool = Spool(
material="PLA-CF",
brand="Bambu Lab",
label_weight=1000,
core_weight=250,
slicer_filament="GFA50",
slicer_filament_name="Bambu PLA-CF",
)
spool.k_profiles = []
spool.assignments = []
db_session.add(spool)
await db_session.flush()
mock_pm = MagicMock()
mock_pm.get_status.return_value = None
mock_pm.get_client.return_value = None
await auto_assign_spool(
printer_id=printer.id,
ams_id=0,
tray_id=3,
spool=spool,
printer_manager=mock_pm,
db=db_session,
tray_info_idx="GFA50",
)
await db_session.commit()
result = await db_session.execute(
sa_select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer.id,
SlotPresetMapping.ams_id == 0,
SlotPresetMapping.tray_id == 3,
)
)
mapping = result.scalar_one()
assert mapping.preset_id == "GFSA50"
assert mapping.preset_name == "Bambu PLA-CF"
@pytest.mark.asyncio
async def test_auto_assign_local_preset_uses_local_prefix(db_session, printer_factory):
"""Spools whose slicer_filament is a numeric local-preset id get saved
with a `local_{n}` preset_id (matches the manual-assign path's shape).
"""
from unittest.mock import MagicMock
from sqlalchemy import select as sa_select
from backend.app.models.slot_preset import SlotPresetMapping
printer = await printer_factory()
spool = Spool(
material="PLA",
brand="Bambu Lab",
label_weight=1000,
core_weight=250,
slicer_filament="50", # numeric → local preset
slicer_filament_name="My Custom PLA",
)
spool.k_profiles = []
spool.assignments = []
db_session.add(spool)
await db_session.flush()
mock_pm = MagicMock()
mock_pm.get_status.return_value = None
mock_pm.get_client.return_value = None
await auto_assign_spool(
printer_id=printer.id,
ams_id=0,
tray_id=0,
spool=spool,
printer_manager=mock_pm,
db=db_session,
)
await db_session.commit()
result = await db_session.execute(
sa_select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer.id,
SlotPresetMapping.ams_id == 0,
SlotPresetMapping.tray_id == 0,
)
)
mapping = result.scalar_one()
assert mapping.preset_id == "local_50"
assert mapping.preset_source == "local"
assert mapping.preset_name == "My Custom PLA"