Ver Fonte

Spoolman: a Bambu spool keeps the tray's filament preset (issue #3433)

- A spool the AMS sync created from a Bambu RFID tray was stored
  without the tray's tray_info_idx, which the built-in inventory keeps
  as slicer_filament. Assigned later to a slot without a reader (the
  external spool) it was written as the generic preset: a Bambu PLA
  Basic became Generic PLA (GFL99).
- Auto-create stores bambu_slicer_filament + its name, as the built-in
  auto-create does. A tag-matched Bambu spool without a preset gets
  the tray's once; one set by hand stays.
- A Spoolman spool without a preset keeps the slot's own filament id
  when it fits, before the generic fallback. The rule is now shared
  (slicer_filament_resolver.reusable_slot_filament_id) and used by
  both inventory modes.
maziggy há 12 horas atrás
pai
commit
5e3a2f2789

+ 1 - 0
CHANGELOG.md

@@ -217,6 +217,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman: a Bambu spool keeps its filament preset (#3433, reported by @ojimpo)** — In Spoolman mode, a spool the AMS sync created from a Bambu RFID tray was stored without the preset the tray reports, which the built-in inventory keeps. Assigned later to a slot without a reader, such as the external spool, it was written as the generic filament for its material: a Bambu PLA Basic became Generic PLA. The sync now stores the tray's preset on a new spool and fills it in on an existing Bambu spool that has none (a preset set by hand stays). And a Spoolman spool without a preset keeps the slot's current filament when it fits, as in built-in mode, before falling back to the generic one.
 - **A camera opened from a printer card stays on the Printers page (#2737)** — Since cameras could be opened from any page, every camera overlay was shown on every page, also one opened from a printer card or the Cam Wall, which followed you around the app. Only a camera opened with the sidebar's **Cameras** button is shown everywhere now. One opened from a card or the Cam Wall is hidden on the other pages, still open, and back on returning to Printers; opening it from the **Cameras** button too puts it on every page. Each camera remembers which it is across a reload.
 - **Spoolman mode no longer shows slots from the built-in inventory (#3430, reported by @sean3318)** — Since switching inventory modes keeps both modes' slot assignments, the Inventory page in Spoolman mode also read the built-in inventory's and gave them priority. Its spool numbers are its own, so a built-in assignment kept from before the switch put a printer slot on whichever Spoolman spool had the same number, and nothing in Spoolman mode could clear it: a spool taken out of the AMS kept its slot. Spoolman mode now only uses Spoolman's slot assignments: on the Inventory page, on the printer card and in SpoolBuddy (a slot's fill level came from the built-in spool too), also while Spoolman is unreachable, when the printer card offered to assign and unassign built-in spools. The built-in assignments are kept for when you switch back.
 - **A spool whose tag is read in a slot leaves its other slots (#3427, reported by @Spegeli)** — When an AMS read a spool's tag, Bambuddy assigned the spool to that slot and left any other slot it was assigned to alone. A spool assigned by hand to an empty slot of one printer and then loaded into another was assigned to both: the first printer's slot was configured from it once a spool went in there, and its prints were charged to a spool sitting in the other printer. A tag read now takes the spool out of its other slots, as moving it by hand does; those slots keep what the printer has set, as after unassigning. A slot whose printer reports the same tag keeps its assignment. Internal inventory and Spoolman alike.

+ 12 - 23
backend/app/api/routes/inventory.py

@@ -83,7 +83,7 @@ from backend.app.services.location_service import (
     rename_location as rename_location_record,
 )
 from backend.app.services.material_number import apply_material_number_inheritance
-from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament, reusable_slot_filament_id
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
@@ -96,14 +96,13 @@ from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.services.supplier_links import apply_supplier_inheritance, apply_supplier_inheritance_to_batch
 from backend.app.services.tag_conflict import tag_already_linked
-from backend.app.utils.filament_finish import different_finish, finish_generic_id
+from backend.app.utils.filament_finish import finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
-    GENERIC_IDS_REPLACED_ON_REUSE,
     filament_id_to_setting_id,
     normalize_slicer_filament,
 )
-from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
+from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
 from backend.app.utils.local_time import day_zone, local_date_end, local_date_start
 from backend.app.utils.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
@@ -179,8 +178,6 @@ async def apply_spool_to_slot_via_mqtt(
     tray_sub_brands = " ".join(p for p in (spool.brand, spool.material, spool.subtype) if p) or spool.material
     tray_color = spool.rgba or "FFFFFFFF"
 
-    _generic_id_values = GENERIC_IDS_REPLACED_ON_REUSE
-
     # Which nozzle this slot feeds, and how wide it is. One resolution shared
     # with every other path that configures a slot (see services.slot_nozzle),
     # and used twice below -- for the spool's per-model preset override and for
@@ -224,23 +221,15 @@ async def apply_spool_to_slot_via_mqtt(
         tray_type = printer_filament_type(type_override)
 
     if not tray_info_idx:
-        if (
-            current_tray_info_idx
-            and current_tray_info_idx not in _generic_id_values
-            and not current_tray_info_idx.startswith("PFUS")
-            and not current_tray_info_idx.startswith("PFCN")
-            # Shares the resolver's reading of what counts as a material
-            # name, product lines included: a slot written by a Bambuddy from
-            # before #2902 can be holding "PLA+" in this field, and reusing
-            # that would carry the bad id forward instead of replacing it.
-            and not is_material_name(current_tray_info_idx)
-            and current_tray_type
-            and current_tray_type.upper() == tray_type.upper()
-            # The same type in another finish is another preset: a Silk spool
-            # does not take the slot's Matte id. Only when both name a finish.
-            and not different_finish(f"{spool.material or ''} {spool.subtype or ''}", current_tray_sub_brands)
-        ):
-            tray_info_idx = current_tray_info_idx
+        reused = reusable_slot_filament_id(
+            current_tray_info_idx,
+            current_tray_type,
+            current_tray_sub_brands,
+            tray_type,
+            f"{spool.material or ''} {spool.subtype or ''}",
+        )
+        if reused:
+            tray_info_idx = reused
         elif tray_type:
             # The spool's own wording is tried first and the reduced type only
             # as a further fallback, so a material that already resolves keeps

+ 34 - 1
backend/app/api/routes/spoolman_inventory.py

@@ -65,7 +65,7 @@ from backend.app.services.location_service import (
     resolve_spoolman_location_string,
 )
 from backend.app.services.printer_manager import PrinterManager, printer_manager
-from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament, reusable_slot_filament_id
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset, resolve_spoolman_preset
 from backend.app.services.spoolman import (
@@ -1875,6 +1875,28 @@ async def sync_spoolman_ams_weights(
     return {"synced": synced, "skipped": skipped, "printing": printing}
 
 
+def _live_slot_tray(state, ams_id: int, tray_id: int) -> dict:
+    """The tray the printer reports for a slot right now, or {} (external: vt_tray 254/255)."""
+    raw = getattr(state, "raw_data", None) if state is not None else None
+    if not isinstance(raw, dict):
+        return {}
+    if ams_id == 255:
+        vt_trays = raw.get("vt_tray") or []
+        for vt in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
+            if isinstance(vt, dict) and str(vt.get("id", 254)) == str(254 + tray_id):
+                return vt
+        return {}
+    units = raw.get("ams") or []
+    if isinstance(units, dict):
+        units = units.get("ams") or []
+    for unit in units if isinstance(units, list) else []:
+        if isinstance(unit, dict) and str(unit.get("id")) == str(ams_id):
+            for tray in unit.get("tray") or []:
+                if isinstance(tray, dict) and str(tray.get("id")) == str(tray_id):
+                    return tray
+    return {}
+
+
 @router.post("/slot-assignments")
 async def assign_spoolman_slot(
     body: SpoolSlotAssignmentRequest,
@@ -2081,6 +2103,17 @@ async def assign_spoolman_slot(
                 tray_type = printer_filament_type(type_override)
 
             material_upper = material.upper().strip()
+            # Without a preset, the slot's own filament id where it fits, as in
+            # internal mode, before any generic one (#3433).
+            if not tray_info_idx:
+                current_tray = _live_slot_tray(state, body.ams_id, body.tray_id)
+                tray_info_idx = reusable_slot_filament_id(
+                    current_tray.get("tray_info_idx"),
+                    current_tray.get("tray_type"),
+                    current_tray.get("tray_sub_brands"),
+                    tray_type,
+                    f"{material} {subtype}",
+                )
             # Fall back to generic-material id when slicer_filament is empty
             # or the resolver discarded an unresolvable value. Matches the
             # internal-mode tail in inventory.py:_apply_spool_to_slot_inner,

+ 37 - 0
backend/app/services/slicer_filament_resolver.py

@@ -43,8 +43,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
+from backend.app.utils.filament_finish import different_finish
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
+    GENERIC_IDS_REPLACED_ON_REUSE,
     filament_id_to_setting_id,
     normalize_slicer_filament,
     orca_to_bambu_filament_id,
@@ -253,6 +255,41 @@ def _preset_filament_type(raw: object) -> str | None:
     return first_slicer_string(raw)
 
 
+def reusable_slot_filament_id(
+    current_tray_info_idx: str | None,
+    current_tray_type: str | None,
+    current_tray_sub_brands: str | None,
+    tray_type: str,
+    spool_text: str,
+) -> str:
+    """The slot's own filament id, when a spool without a preset may keep it.
+
+    A spool whose preset is empty or unresolvable would otherwise get the
+    generic id for its material, which turns a genuine Bambu PLA Basic into
+    Generic PLA. When the slot already holds a specific filament id of the same
+    type, keeping it is closer. Shared by both inventory modes (#3433): the
+    Spoolman route went straight to the generic id.
+
+    Not kept: a generic id, a cloud user preset (PFUS/PFCN), a material name
+    left in that field by a Bambuddy from before #2902, another type, and
+    another finish when both name one (a Silk spool does not take a Matte id).
+    ``spool_text`` is the spool's material and subtype.
+    """
+    current = (current_tray_info_idx or "").strip()
+    if (
+        current
+        and current not in GENERIC_IDS_REPLACED_ON_REUSE
+        and not current.startswith("PFUS")
+        and not current.startswith("PFCN")
+        and not is_material_name(current)
+        and current_tray_type
+        and current_tray_type.upper() == tray_type.upper()
+        and not different_finish(spool_text, current_tray_sub_brands)
+    ):
+        return current
+    return ""
+
+
 async def resolve_slicer_filament(
     *,
     db: AsyncSession,

+ 56 - 0
backend/app/services/spoolman.py

@@ -66,6 +66,30 @@ class SpoolmanClientError(Exception):
         self.response_text = response_text
 
 
+def _tray_preset_extra(tray: AMSTray) -> dict:
+    """The tray's filament preset as Spoolman spool ``extra`` fields, or {}.
+
+    Same values an internal RFID spool gets (``spool_tag_matcher``): the
+    tray's filament id, named from the built-in table, else by the tray's
+    sub-brand.
+    """
+    preset = (tray.tray_info_idx or "").strip()
+    if not preset:
+        return {}
+    name = None
+    try:
+        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+        name = _BUILTIN_FILAMENT_NAMES.get(preset)
+    except Exception:  # noqa: BLE001 - a display name; the preset is what counts
+        name = None
+    name = name or (tray.tray_sub_brands or "").strip() or None
+    fields = {"bambu_slicer_filament": json.dumps(preset)}
+    if name:
+        fields["bambu_slicer_filament_name"] = json.dumps(name)
+    return fields
+
+
 def _filament_subtype_part(name: str, material: str) -> str:
     """Return the subtype portion of a filament name, lowercased.
 
@@ -779,6 +803,30 @@ class SpoolmanClient:
             self._extra_locks[spool_id] = lock
         return lock
 
+    async def _fill_missing_tray_preset(self, spool: dict, tray: AMSTray) -> None:
+        """Give a tag-matched Bambu spool the tray's preset when it has none (#3433).
+
+        Spools the AMS sync created before it stored the preset have none, as do
+        ones linked to a tag some other way. As an internal spool gets one when
+        its tag is linked. A preset already set, by hand or otherwise, stays.
+        Best effort: the sync goes on without it.
+        """
+        fields = _tray_preset_extra(tray)
+        if not fields:
+            return
+        extra = spool.get("extra") or {}
+        current = extra.get("bambu_slicer_filament") if isinstance(extra, dict) else None
+        try:
+            if isinstance(current, str) and json.loads(current):
+                return
+        except (ValueError, TypeError):
+            return  # something is there; not ours to replace
+        try:
+            await self.merge_spool_extra(spool["id"], fields)
+            logger.info("Stored the tray's preset %s on Spoolman spool %s", tray.tray_info_idx, spool["id"])
+        except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as e:
+            logger.warning("Could not store the tray's preset on Spoolman spool %s: %s", spool["id"], e)
+
     async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
         """Fetch the spool's extra dict, merge new_fields into it, then PATCH back — serialised per spool."""
         async with self.extra_lock(spool_id):
@@ -1387,6 +1435,8 @@ class SpoolmanClient:
                 # Native tags collect what the AMS reads: the tray UUID, and the chip
                 # UID of whichever side faces the reader, so both sides end up linked.
                 await self.add_native_tags(existing, [tray.tray_uuid, chip_uid], tag_format)
+                if is_bambu:
+                    await self._fill_missing_tray_preset(existing, tray)
                 return await self.update_spool(
                     spool_id=existing["id"],
                     remaining_weight=None if disable_weight_sync else remaining,
@@ -1438,6 +1488,12 @@ class SpoolmanClient:
             # _ensure_extra_fields registers the key from the payload (#2903).
             if catalog_color is not None:
                 extra["bambu_color_name"] = json.dumps(catalog_color.name)
+            # The tag's filament preset, as an internal RFID spool keeps it as
+            # slicer_filament (#3433). Without it a slot this spool is later
+            # assigned to -- the external one has no reader -- got the generic
+            # preset for its material.
+            if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
+                extra.update(_tray_preset_extra(tray))
             created = await self.create_spool(
                 filament_id=filament_id,
                 remaining_weight=remaining,

+ 95 - 0
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -1098,3 +1098,98 @@ class TestSlicerFilamentResolutionParity:
         call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
         assert call_kwargs["tray_info_idx"] == expected
         assert call_kwargs["setting_id"] == "GFS" + expected[2:]
+
+
+class TestSpoolWithoutPresetKeepsTheSlotsFilament:
+    """#3433: a Spoolman spool without a preset went straight to the generic id
+    for its material, so a genuine Bambu PLA Basic on the external slot became
+    Generic PLA. Internal mode first keeps the slot's own filament id when it
+    fits; the Spoolman route now asks the same shared rule."""
+
+    async def _assign(self, async_client, printer, *, ams_id, tray_id, raw_data, spool=None, client=None):
+        if spool is not None:
+            client.get_spool = AsyncMock(return_value=spool)
+        mqtt_mock = MagicMock()
+        state = MagicMock()
+        state.raw_data = raw_data
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=state)
+            pm_mock.get_model = MagicMock(return_value="X2D")
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={"spoolman_spool_id": 10, "printer_id": printer.id, "ams_id": ams_id, "tray_id": tray_id},
+            )
+        assert response.status_code == 200
+        return mqtt_mock.ams_set_filament_setting.call_args[1]["tray_info_idx"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_reporters_external_slot_keeps_pla_basic(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        raw = {"vt_tray": [{"id": "255", "tray_type": "PLA", "tray_info_idx": "GFA00", "tray_sub_brands": "PLA Basic"}]}
+        assert await self._assign(async_client, test_printer, ams_id=255, tray_id=1, raw_data=raw) == "GFA00"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_ams_slot_keeps_its_filament_too(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        raw = {"ams": [{"id": "0", "tray": [{"id": "1", "tray_type": "PLA", "tray_info_idx": "GFA00"}]}]}
+        assert await self._assign(async_client, test_printer, ams_id=0, tray_id=1, raw_data=raw) == "GFA00"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("current", "expected"),
+        [
+            ({"tray_type": "PETG", "tray_info_idx": "GFG00"}, "GFL99"),  # another type
+            ({"tray_type": "PLA", "tray_info_idx": "PFUS1234abcd"}, "GFL99"),  # a cloud user preset
+            ({"tray_type": "PLA", "tray_info_idx": "GFL99"}, "GFL99"),  # already generic
+            ({"tray_type": "PLA", "tray_info_idx": "PLA+"}, "GFL99"),  # a material name, pre-#2902
+            ({}, "GFL99"),  # nothing reported
+        ],
+    )
+    async def test_falls_back_to_the_generic_id_when_the_slot_does_not_fit(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, current, expected
+    ):
+        raw = {"ams": [{"id": "0", "tray": [{"id": "1", **current}]}]}
+        assert await self._assign(async_client, test_printer, ams_id=0, tray_id=1, raw_data=raw) == expected
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_another_finish_is_not_kept(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        silk = {**SAMPLE_SPOOL, "filament": {**SAMPLE_SPOOL["filament"], "name": "PLA Silk+"}}
+        raw = {
+            "ams": [
+                {
+                    "id": "0",
+                    "tray": [{"id": "1", "tray_type": "PLA", "tray_info_idx": "GFA01", "tray_sub_brands": "PLA Matte"}],
+                }
+            ]
+        }
+        tray_info_idx = await self._assign(
+            async_client, test_printer, ams_id=0, tray_id=1, raw_data=raw, spool=silk, client=mock_spoolman_client
+        )
+        assert tray_info_idx == "GFL96"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_spools_own_preset_still_wins(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        with_preset = {**SAMPLE_SPOOL, "extra": {"bambu_slicer_filament": json.dumps("GFA01")}}
+        raw = {"ams": [{"id": "0", "tray": [{"id": "1", "tray_type": "PLA", "tray_info_idx": "GFA00"}]}]}
+        tray_info_idx = await self._assign(
+            async_client,
+            test_printer,
+            ams_id=0,
+            tray_id=1,
+            raw_data=raw,
+            spool=with_preset,
+            client=mock_spoolman_client,
+        )
+        assert tray_info_idx == "GFA01"

+ 2 - 0
backend/tests/unit/services/test_spoolman_service.py

@@ -369,6 +369,8 @@ class TestSpoolmanClient:
         with (
             patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
             patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
+            # The tray's preset is filled in on the spool (#3433); that read is not the hint's.
+            patch.object(client, "merge_spool_extra", AsyncMock(return_value={})),
             patch.object(client, "get_spool", AsyncMock()) as mock_get_spool,
         ):
             result = await client.sync_ams_tray(

+ 114 - 0
backend/tests/unit/services/test_spoolman_tray_preset_3433.py

@@ -0,0 +1,114 @@
+"""The AMS sync keeps the tray's filament preset on the Spoolman spool (#3433).
+
+An internal RFID spool stores the tray's ``tray_info_idx`` as its
+``slicer_filament``. A Spoolman spool the sync created from the same tray got
+only its tag, so assigning it to the external slot later -- no reader there --
+wrote the generic preset for its material: a Bambu PLA Basic became Generic PLA.
+"""
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.spoolman import AMSTray, SpoolmanClient, SpoolmanUnavailableError
+
+UUID = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
+
+
+def _tray(*, preset="GFA00", uuid=UUID, tag="", sub_brands="PLA Basic"):
+    return AMSTray(
+        ams_id=0,
+        tray_id=0,
+        tray_type="PLA",
+        tray_sub_brands=sub_brands,
+        tray_color="000000FF",
+        remain=100,
+        tag_uid=tag,
+        tray_uuid=uuid,
+        tray_info_idx=preset,
+        tray_weight=1000,
+    )
+
+
+@pytest.fixture
+def client():
+    return SpoolmanClient("http://localhost:7912")
+
+
+async def _create(client, tray):
+    """Run the sync's auto-create for an unknown tag; returns (create_spool mock, profile-default fill mock)."""
+    with (
+        patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
+        patch.object(client, "_find_or_create_filament", AsyncMock(return_value={"id": 5})),
+        patch.object(client, "find_or_create_filament", AsyncMock(return_value=5)),
+        patch.object(client, "create_spool", AsyncMock(return_value={"id": 9, "extra": {}})) as create,
+        patch.object(client, "add_native_tags", AsyncMock()),
+        patch("backend.app.services.spoolman.resolve_bambu_color", AsyncMock(return_value=None)),
+        patch("backend.app.services.filament_profile_default.fill_auto_added_spoolman_spool", AsyncMock()) as fill,
+    ):
+        await client.sync_ams_tray(tray, "X2D", None, auto_add_unknown_rfid=True)
+    return create, fill
+
+
+class TestAutoCreate:
+    @pytest.mark.asyncio
+    async def test_a_bambu_tray_s_preset_is_stored_with_its_name(self, client):
+        create, fill = await _create(client, _tray())
+        extra = create.call_args.kwargs["extra"]
+        assert extra["bambu_slicer_filament"] == json.dumps("GFA00")
+        assert extra["bambu_slicer_filament_name"] == json.dumps("Bambu PLA Basic")
+        # The tag's preset is the spool's, so a profile default's is not applied.
+        assert fill.call_args.kwargs["include_slicer"] is False
+
+    @pytest.mark.asyncio
+    async def test_an_unlisted_preset_is_named_by_the_sub_brand(self, client):
+        create, _ = await _create(client, _tray(preset="GFZ77", sub_brands="PLA Something"))
+        extra = create.call_args.kwargs["extra"]
+        assert extra["bambu_slicer_filament"] == json.dumps("GFZ77")
+        assert extra["bambu_slicer_filament_name"] == json.dumps("PLA Something")
+
+    @pytest.mark.asyncio
+    async def test_a_third_party_tag_gets_no_preset(self, client):
+        create, fill = await _create(client, _tray(preset="", uuid="0" * 32, tag="0A1B2C3D4E5F6071"))
+        extra = create.call_args.kwargs["extra"]
+        assert "bambu_slicer_filament" not in extra
+        assert fill.call_args.kwargs["include_slicer"] is True
+
+
+class TestExistingSpool:
+    async def _sync(self, client, spool, *, merge=None):
+        merge = merge or AsyncMock(return_value={})
+        with (
+            patch.object(client, "find_spool_by_tag", AsyncMock(return_value=spool)),
+            patch.object(client, "add_native_tags", AsyncMock()),
+            patch.object(client, "update_spool", AsyncMock(return_value={"id": spool["id"]})) as update,
+            patch.object(client, "merge_spool_extra", merge),
+        ):
+            result = await client.sync_ams_tray(_tray(), "X2D", None)
+        return merge, update, result
+
+    @pytest.mark.asyncio
+    async def test_a_spool_without_a_preset_gets_the_tray_s_once(self, client):
+        merge, _, _ = await self._sync(client, {"id": 6, "extra": {"tag": json.dumps(UUID)}})
+        merge.assert_awaited_once_with(
+            6,
+            {"bambu_slicer_filament": json.dumps("GFA00"), "bambu_slicer_filament_name": json.dumps("Bambu PLA Basic")},
+        )
+
+    @pytest.mark.asyncio
+    async def test_an_empty_preset_counts_as_none(self, client):
+        merge, _, _ = await self._sync(client, {"id": 6, "extra": {"bambu_slicer_filament": json.dumps("")}})
+        merge.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_a_preset_set_by_hand_stays(self, client):
+        merge, _, _ = await self._sync(client, {"id": 6, "extra": {"bambu_slicer_filament": json.dumps("GFA01")}})
+        merge.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_spoolman_refusing_the_write_does_not_stop_the_sync(self, client):
+        merge = AsyncMock(side_effect=SpoolmanUnavailableError("down"))
+        _, update, result = await self._sync(client, {"id": 6, "extra": {}}, merge=merge)
+        update.assert_awaited_once()
+        assert result == {"id": 6}