Files
bambuddy/backend/app/services/slot_preset_writer.py
T
maziggy 0793022818 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.
2026-06-10 08:39:21 +02:00

159 lines
5.2 KiB
Python

"""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",
)