Ver Fonte

Name an AMS slot after the spool assigned to it

The print dialog described every slot from the printer's own telemetry, and
a printer cannot describe a spool it did not sell: a tray record carries no
brand field, tray_sub_brands is left empty for anything that is not a Bambu
spool, and the colour arrives as a bare hex the client resolves against
Bambu's own colour catalogue. A Devil Design PLA Basic Orange assigned in
Bambuddy therefore read as "PLA (Sunflower Yellow)" -- Bambu sell a
Sunflower Yellow at the same FEC600 -- while the printer card, which reads
the assignment, named it correctly. Two views of one slot, disagreeing.

GET /printers/{id}/inventory-remain now carries each bound slot's brand,
material, subtype, colour name and hex alongside the pooling key it already
sent, and the dialog prefers that over telemetry. The fallback is per field,
not all or nothing, so a spool with no stored colour name still gets the
catalogue lookup it had before while its brand and subtype come from the
binding. Resolved server-side because the identity rule differs per
inventory mode -- brand is a column in internal mode and a nested vendor in
Spoolman's, where the subtype is the filament name with its material prefix
stripped and the colour name has a three-step read order Spoolman has no
field for. Spoolman's synthesised colour name, which falls back to the
subtype, is withheld rather than rendered as "PLA Basic (Basic)".

Matching is deliberately untouched and still runs on the printer's
telemetry. The auto-assignment, the colour-mismatch test and the mapping
that actually gets dispatched all read type, colour hex and tray_info_idx,
so renaming a slot cannot make the panel and the dispatcher draw different
conclusions from it. The payload is re-read on every open of the dialog: it
names the slots now, and a spool assigned moments earlier would otherwise
keep its old name for the rest of the thirty-second stale window. Done at
the two readers rather than by invalidating the key from each of the
eighteen places a binding or a spool can change, half of which are internal
paths and half Spoolman ones -- covering some would make freshness depend on
which mode you run.

Two hardening fixes fall out of putting a mapper on this path.
build_slot_materials runs before every queue start through
compute_deficit_for_queue_item, and _map_spoolman_spool walks a dozen nested
fields off the wire, any of which arriving as the wrong type raises
AttributeError rather than ValueError. Naming a slot must never cost a
dispatch, so that call fails soft to no name. The same inputs also reached
_material_identity_spoolman and _normalize_color_for_id, which have always
been on this path and would fail a queue start on a Spoolman record whose
filament is not a dict or whose color_hex is a number; both now read those
as "nothing to pool with". Behaviour for well-formed input is unchanged --
the guards only intercept types that previously raised -- so no pooling key
moves and AMS Filament Backup is untouched.
maziggy há 1 semana atrás
pai
commit
d5c7047765

+ 1 - 0
CHANGELOG.md

@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 
 
 ### Fixed
 ### Fixed
+- **The print dialog named an AMS slot after the wrong spool** — The slot dropdown described every slot from the printer's own telemetry, and the printer cannot describe a spool it did not sell: a tray record has no brand field, `tray_sub_brands` is left empty for anything that is not a Bambu spool, and the colour is a bare hex the client resolves against Bambu's colour catalogue. A Devil Design PLA Basic Orange assigned in Bambuddy therefore read as "PLA (Sunflower Yellow)" — Bambu sell a Sunflower Yellow at the same `FEC600` — while the printer card, which reads the assignment, named it correctly. The two views now agree: `GET /printers/{id}/inventory-remain` carries each bound slot's brand, material, subtype, colour name and hex alongside the pooling key it already sent, and the dialog prefers that over telemetry, falling back field by field so a spool with no stored colour name still gets the catalogue lookup it had before, and re-reading it on every open so a spool assigned moments earlier is named correctly straight away. Resolved server-side, so internal inventory and Spoolman mode answer identically rather than the client re-deriving a rule that differs per mode. Matching is deliberately untouched and still runs on the printer's telemetry, so the auto-assignment and the colour-mismatch warning cannot start disagreeing with what the dispatcher does.
 - **A K profile could be saved against the wrong hotend, and applied to the wrong one** — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the **right** hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix.
 - **A K profile could be saved against the wrong hotend, and applied to the wrong one** — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the **right** hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix.
 - **RFID auto-assign picked a K profile without checking which hotend it was calibrated on** — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded.
 - **RFID auto-assign picked a K profile without checking which hotend it was calibrated on** — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded.
 - **Linking a Spoolman spool by tag configured the slot as generic filament** — That path resolved no slicer preset whatsoever and went straight to the generic material id, so a Spoolman spool with a preset set in inventory lost it the moment it was linked by tag. The same defect #1713 fixed on the assign path, in the function next door.
 - **Linking a Spoolman spool by tag configured the slot as generic filament** — That path resolved no slicer preset whatsoever and went straight to the generic material id, so a Spoolman spool with a preset set in inventory lost it the moment it was linked by tag. The same defect #1713 fixed on the assign path, in the function next door.

+ 131 - 3
backend/app/services/filament_deficit.py

@@ -38,6 +38,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -186,7 +187,15 @@ def _normalize_color_for_id(raw: str | None) -> str:
     Strips the leading ``#``, uppercases, and drops the alpha channel when
     Strips the leading ``#``, uppercases, and drops the alpha channel when
     the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
     the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
     matches a 6-char hex of the same RGB. Empty / None → empty string.
     matches a 6-char hex of the same RGB. Empty / None → empty string.
+
+    Anything that is not a string reads as "no colour" rather than raising.
+    In Spoolman mode ``raw`` comes straight off the wire as
+    ``filament.color_hex``, and this runs on the dispatch path — a record
+    holding a number there would otherwise fail a queue start rather than
+    merely fail to pool.
     """
     """
+    if not isinstance(raw, str):
+        raw = None
     s = (raw or "").strip().lstrip("#").upper()
     s = (raw or "").strip().lstrip("#").upper()
     if len(s) == 8:  # RRGGBBAA → strip alpha
     if len(s) == 8:  # RRGGBBAA → strip alpha
         s = s[:6]
         s = s[:6]
@@ -225,9 +234,14 @@ def _material_identity_spoolman(spool: dict | None) -> str:
     pins the variant. Spools without a resolvable filament id get a
     pins the variant. Spools without a resolvable filament id get a
     per-spool unique key so they never pair.
     per-spool unique key so they never pair.
     """
     """
-    if not spool:
+    if not isinstance(spool, dict) or not spool:
         return "unmatched:none"
         return "unmatched:none"
+    # Both are free-form JSON off the Spoolman API, and this is the dispatch
+    # path: a wrongly-typed member must cost the slot its pool, never the
+    # queue its start.
     filament = spool.get("filament") or {}
     filament = spool.get("filament") or {}
+    if not isinstance(filament, dict):
+        filament = {}
     fil_id = filament.get("id")
     fil_id = filament.get("id")
     if isinstance(fil_id, (int, str)) and str(fil_id).strip():
     if isinstance(fil_id, (int, str)) and str(fil_id).strip():
         # Prefer the per-spool override colour when set (Spoolman lets the user
         # Prefer the per-spool override colour when set (Spoolman lets the user
@@ -301,6 +315,102 @@ async def _get_printer_backup_context(
     return backup_on, ams_extruder_map, is_dual
     return backup_on, ams_extruder_map, is_dual
 
 
 
 
+@dataclass(frozen=True)
+class SlotSpoolIdentity:
+    """How the spool bound to a slot should be *named*, as opposed to matched.
+
+    The printer cannot supply this and never will. A tray record carries no
+    brand field at all, and ``tray_sub_brands`` stays empty for anything that
+    isn't a Bambu spool, so a client naming a slot from telemetry alone has
+    only the type and the colour hex to work with — and turns that hex into
+    whichever catalogue colour happens to share it. A Devil Design PLA Basic
+    Orange the operator assigned in Bambuddy reads back as "PLA (Sunflower
+    Yellow)", because Bambu sell a Sunflower Yellow at the same ``FEC600``.
+
+    Only the assignment knows the answer, which is why it is served alongside
+    the pooling key rather than left to the client to resolve: the identity
+    rule differs per inventory mode, and the printer card and the print dialog
+    disagreeing about what is in a slot is the bug this exists to close.
+
+    Purely descriptive — nothing here takes part in matching, which stays on
+    the printer's own telemetry so the dialog and the dispatcher cannot draw
+    different conclusions from the same slot.
+    """
+
+    brand: str | None
+    material: str | None
+    subtype: str | None
+    color_name: str | None
+    rgba: str | None
+
+    def to_dict(self) -> dict:
+        return {
+            "brand": self.brand,
+            "material": self.material,
+            "subtype": self.subtype,
+            "color_name": self.color_name,
+            "rgba": self.rgba,
+        }
+
+
+def _clean(value) -> str | None:
+    """Trim a display field, collapsing blanks to None so the client can skip it."""
+    text = str(value).strip() if value is not None else ""
+    return text or None
+
+
+def _identity_from_internal(spool) -> SlotSpoolIdentity:
+    """Display identity from an internal-inventory ``Spool`` row."""
+    return SlotSpoolIdentity(
+        brand=_clean(spool.brand),
+        material=_clean(spool.material),
+        subtype=_clean(spool.subtype),
+        color_name=_clean(spool.color_name),
+        rgba=_clean(spool.rgba),
+    )
+
+
+def _identity_from_spoolman(spool_dict: dict) -> SlotSpoolIdentity | None:
+    """Display identity from a raw Spoolman spool dict, or None if unreadable.
+
+    Goes through ``_map_spoolman_spool`` rather than reading the dict directly:
+    brand lives on the nested vendor, subtype is the filament name with its
+    material prefix stripped, and ``color_name`` has a three-step read order
+    Spoolman itself has no field for. Re-deriving any of that here is how the
+    two modes would drift apart.
+    """
+    # Broad on purpose. This is a name for a dropdown, and the caller is on the
+    # dispatch path -- ``compute_deficit_for_queue_item`` runs it before every
+    # queue start. ``_map_spoolman_spool`` walks a dozen nested fields off the
+    # wire (``filament.vendor.name``, ``extra.tag``, ``filament.color_hex``) and
+    # any of them arriving as the wrong type raises AttributeError rather than
+    # ValueError, so a narrow catch here would turn one malformed Spoolman
+    # record into a failed dispatch. Losing the name costs a fallback to
+    # telemetry, which is what every slot did before this existed.
+    try:
+        mapped = _map_spoolman_spool(spool_dict)
+    except Exception as exc:  # noqa: BLE001 - display-only, must never block a dispatch
+        logger.debug(
+            "Spoolman spool %r has no usable display identity: %s",
+            spool_dict.get("id") if isinstance(spool_dict, dict) else spool_dict,
+            exc,
+        )
+        return None
+    # Spoolman has no colour-name field, so `_map_spoolman_spool` synthesises
+    # one from the subtype when nothing is stored -- which reads fine in an
+    # inventory list ("PLA Basic") and badly as a colour ("Devil Design PLA
+    # Basic (Basic)"). Drop it and let the client's catalogue lookup name the
+    # hex, which is what an unnamed slot got before this existed.
+    color_name = None if mapped.get("color_name_is_synthesized") else _clean(mapped.get("color_name"))
+    return SlotSpoolIdentity(
+        brand=_clean(mapped.get("brand")),
+        material=_clean(mapped.get("material")),
+        subtype=_clean(mapped.get("subtype")),
+        color_name=color_name,
+        rgba=_clean(mapped.get("rgba")),
+    )
+
+
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class SlotMaterial:
 class SlotMaterial:
     """One inventory-bound AMS slot: what's in it, how much is left, which side."""
     """One inventory-bound AMS slot: what's in it, how much is left, which side."""
@@ -314,6 +424,9 @@ class SlotMaterial:
     material_key: str
     material_key: str
     remaining_grams: float
     remaining_grams: float
     extruder: int
     extruder: int
+    # Display-only; see SlotSpoolIdentity. None when the binding resolves to a
+    # spool we cannot describe, which callers render from telemetry as before.
+    spool: SlotSpoolIdentity | None = None
 
 
     def to_dict(self) -> dict:
     def to_dict(self) -> dict:
         return {
         return {
@@ -323,6 +436,7 @@ class SlotMaterial:
             "material_key": self.material_key,
             "material_key": self.material_key,
             "remaining_g": self.remaining_grams,
             "remaining_g": self.remaining_grams,
             "extruder": self.extruder,
             "extruder": self.extruder,
+            "spool": self.spool.to_dict() if self.spool else None,
         }
         }
 
 
 
 
@@ -344,7 +458,13 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
     _, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
     _, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
     materials: list[SlotMaterial] = []
     materials: list[SlotMaterial] = []
 
 
-    def _append(ams_id: int, tray_id: int, material_key: str, remaining: float) -> None:
+    def _append(
+        ams_id: int,
+        tray_id: int,
+        material_key: str,
+        remaining: float,
+        spool: SlotSpoolIdentity | None = None,
+    ) -> None:
         materials.append(
         materials.append(
             SlotMaterial(
             SlotMaterial(
                 ams_id=ams_id,
                 ams_id=ams_id,
@@ -353,6 +473,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                 material_key=material_key,
                 material_key=material_key,
                 remaining_grams=remaining,
                 remaining_grams=remaining,
                 extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
                 extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
+                spool=spool,
             )
             )
         )
         )
 
 
@@ -391,7 +512,13 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                     remaining = max(0.0, float(total) - float(used))
                     remaining = max(0.0, float(total) - float(used))
             if remaining is None:
             if remaining is None:
                 continue
                 continue
-            _append(sa.ams_id, sa.tray_id, _material_identity_spoolman(spool_dict), remaining)
+            _append(
+                sa.ams_id,
+                sa.tray_id,
+                _material_identity_spoolman(spool_dict),
+                remaining,
+                _identity_from_spoolman(spool_dict),
+            )
         return materials
         return materials
 
 
     internal_all = await db.execute(
     internal_all = await db.execute(
@@ -412,6 +539,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
             assignment.tray_id,
             assignment.tray_id,
             _material_identity_internal(spool),
             _material_identity_internal(spool),
             max(0.0, label_weight - weight_used),
             max(0.0, label_weight - weight_used),
+            _identity_from_internal(spool),
         )
         )
     return materials
     return materials
 
 

+ 286 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -935,3 +935,289 @@ class TestBuildSlotMaterials:
                 p.stop()
                 p.stop()
 
 
         assert slots == []
         assert slots == []
+
+
+class TestSlotSpoolIdentity:
+    """The display half of ``build_slot_materials``.
+
+    A tray record has no brand field, and ``tray_sub_brands`` stays empty for
+    anything that isn't a Bambu spool, so a client naming a slot from telemetry
+    alone has only the type and a colour hex — which it resolves against
+    Bambu's own colour catalogue. The reporter's Devil Design PLA Basic Orange
+    therefore read as "PLA (Sunflower Yellow)" in the print dialog while the
+    printer card, which reads the assignment, named it correctly.
+
+    Descriptive only: nothing here takes part in matching, which stays on the
+    printer's telemetry so the dialog and the dispatcher cannot disagree.
+    """
+
+    @pytest.mark.asyncio
+    async def test_internal_mode_carries_what_the_printer_cannot_say(self, db_session, printer_factory):
+        """Brand and subtype exist nowhere in the telemetry for a third-party spool."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2C")
+        spool = Spool(
+            brand="Devil Design",
+            material="PLA",
+            subtype="Basic",
+            color_name="Orange",
+            rgba="FEC600FF",
+            label_weight=1000,
+            weight_used=0.0,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=2, tray_id=0)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="H2C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        assert len(slots) == 1
+        identity = slots[0].spool
+        assert identity is not None
+        assert identity.to_dict() == {
+            "brand": "Devil Design",
+            "material": "PLA",
+            "subtype": "Basic",
+            # The hex is FEC600, which is also Bambu's "Sunflower Yellow" —
+            # naming this slot from the hex is exactly the bug.
+            "color_name": "Orange",
+            "rgba": "FEC600FF",
+        }
+
+    @pytest.mark.asyncio
+    async def test_blank_fields_become_null_so_the_client_can_fall_back(self, db_session, printer_factory):
+        """Per-field, not all-or-nothing: an unnamed colour still falls back to
+        the catalogue lookup while the brand and subtype come from the spool."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2C")
+        spool = Spool(
+            brand="  ",
+            material="PLA",
+            subtype="Silk+",
+            color_name=None,
+            rgba="5F6367FF",
+            label_weight=1000,
+            weight_used=0.0,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=2)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="H2C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        identity = slots[0].spool
+        assert identity is not None
+        assert identity.brand is None
+        assert identity.color_name is None
+        assert identity.subtype == "Silk+"
+
+    @pytest.mark.asyncio
+    async def test_spoolman_mode_reaches_the_same_shape(self, db_session, printer_factory):
+        """Parity (#1390): brand off the nested vendor, subtype from the
+        filament name with its material prefix stripped. Derived through
+        ``_map_spoolman_spool`` rather than re-read here, which is what stops
+        the two inventory modes drifting apart."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2C")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=2, tray_id=0, spoolman_spool_id=42))
+        await db_session.commit()
+
+        client = AsyncMock()
+        client.get_spool = AsyncMock(
+            return_value={
+                "id": 42,
+                "remaining_weight": 800.0,
+                "extra": {"bambu_color_name": '"Orange"'},
+                "filament": {
+                    "id": 7,
+                    "name": "PLA Basic",
+                    "material": "PLA",
+                    "color_hex": "FEC600",
+                    "weight": 1000,
+                    "vendor": {"id": 3, "name": "Devil Design"},
+                },
+            }
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="H2C")
+        for p in patches:
+            p.start()
+        try:
+            with patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                AsyncMock(return_value=client),
+            ):
+                slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        identity = slots[0].spool
+        assert identity is not None
+        assert identity.brand == "Devil Design"
+        assert identity.material == "PLA"
+        assert identity.subtype == "Basic"
+        assert identity.color_name == "Orange"
+        assert identity.rgba == "FEC600FF"
+
+    @pytest.mark.asyncio
+    async def test_spoolman_synthesised_colour_name_is_dropped(self, db_session, printer_factory):
+        """Spoolman has no colour-name field, so `_map_spoolman_spool` falls
+        back to the subtype when nothing is stored. That reads fine in an
+        inventory list and badly as a colour — "Devil Design PLA Basic
+        (Basic)". Withheld, so the client names the hex as it did before."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2C")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=9))
+        await db_session.commit()
+
+        client = AsyncMock()
+        # No extra.bambu_color_name and no filament.color_name — the two places
+        # a real one can come from.
+        client.get_spool = AsyncMock(
+            return_value={
+                "id": 9,
+                "remaining_weight": 500.0,
+                "filament": {
+                    "id": 2,
+                    "name": "PLA Basic",
+                    "material": "PLA",
+                    "color_hex": "FEC600",
+                    "vendor": {"id": 1, "name": "Devil Design"},
+                },
+            }
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="H2C")
+        for p in patches:
+            p.start()
+        try:
+            with patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                AsyncMock(return_value=client),
+            ):
+                slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        identity = slots[0].spool
+        assert identity is not None
+        assert identity.subtype == "Basic"
+        assert identity.color_name is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "payload",
+        [
+            {"id": 1, "remaining_weight": 500.0, "filament": "PLA"},
+            {"id": 1, "remaining_weight": 500.0, "filament": {"id": 2}, "extra": "nope"},
+            {"id": 1, "remaining_weight": 500.0, "filament": {"id": 2}, "extra": {"tag": 12345}},
+            {"id": 1, "remaining_weight": 500.0, "filament": {"id": 2, "color_hex": 255}},
+            {"id": 1, "remaining_weight": 500.0, "filament": {"id": 2, "vendor": ["x"]}},
+        ],
+        ids=["filament-not-a-dict", "extra-not-a-dict", "tag-not-a-str", "hex-not-a-str", "vendor-not-a-dict"],
+    )
+    async def test_malformed_spoolman_payload_cannot_break_a_dispatch(self, db_session, printer_factory, payload):
+        """Naming a slot must never cost a queue start.
+
+        ``build_slot_materials`` is on the dispatch path — every queue start
+        runs it through ``compute_deficit_for_queue_item``. The mapper walks a
+        dozen nested wire fields, and each of these arrives as the wrong type
+        and raises AttributeError, not ValueError.
+        """
+        from unittest.mock import AsyncMock
+
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="X1C")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=1))
+        await db_session.commit()
+
+        client = AsyncMock()
+        client.get_spool = AsyncMock(return_value=payload)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="X1C")
+        for p in patches:
+            p.start()
+        try:
+            with patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                AsyncMock(return_value=client),
+            ):
+                slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        # The slot keeps its grams — only the name is lost.
+        assert len(slots) == 1
+        assert slots[0].remaining_grams == 500.0
+        assert slots[0].spool is None
+
+    @pytest.mark.asyncio
+    async def test_unreadable_spoolman_spool_keeps_the_slot_but_drops_the_name(self, db_session, printer_factory):
+        """A spool we cannot describe must not cost the slot its place in the
+        pool — the backup accounting still needs its grams. The client falls
+        back to telemetry for the name, exactly as before this existed."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="X1C")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=5))
+        await db_session.commit()
+
+        client = AsyncMock()
+        # No id — `_map_spoolman_spool` raises, and only the naming is lost.
+        client.get_spool = AsyncMock(return_value={"remaining_weight": 500.0, "filament": {"id": 1}})
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="X1C")
+        for p in patches:
+            p.start()
+        try:
+            with patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                AsyncMock(return_value=client),
+            ):
+                slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        assert len(slots) == 1
+        assert slots[0].remaining_grams == 500.0
+        assert slots[0].spool is None
+        assert slots[0].to_dict()["spool"] is None

+ 61 - 1
backend/tests/unit/test_inventory_remain_endpoint.py

@@ -17,7 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 import pytest
 
 
 from backend.app.api.routes.printers import get_inventory_remain
 from backend.app.api.routes.printers import get_inventory_remain
-from backend.app.services.filament_deficit import SlotMaterial
+from backend.app.services.filament_deficit import SlotMaterial, SlotSpoolIdentity
 
 
 
 
 @pytest.fixture
 @pytest.fixture
@@ -145,5 +145,65 @@ class TestGetInventoryRemain:
                 "material_key": "preset:PFUS6488|color:616777",
                 "material_key": "preset:PFUS6488|color:616777",
                 "remaining_g": 1000.0,
                 "remaining_g": 1000.0,
                 "extruder": 0,
                 "extruder": 0,
+                # Present even when there is nothing to say, so the client can
+                # branch on the field rather than on its absence.
+                "spool": None,
             }
             }
         ]
         ]
+
+    @pytest.mark.asyncio
+    async def test_slot_materials_carry_the_bound_spool_s_display_identity(self, db):
+        """What the printer cannot say about a slot has to reach the client here.
+
+        A tray record has no brand field and reports no sub-brand for anything
+        that isn't a Bambu spool, so the print dialog named the reporter's
+        Devil Design PLA Basic Orange after whichever catalogue colour shares
+        its hex — "PLA (Sunflower Yellow)" — while the printer card, which
+        reads the assignment, had it right.
+        """
+        state = SimpleNamespace(raw_data={})
+        with (
+            patch(
+                "backend.app.services.printer_manager.printer_manager.get_status",
+                return_value=state,
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
+                return_value=[],
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
+                new=AsyncMock(return_value={}),
+            ),
+            patch(
+                "backend.app.services.filament_deficit.build_slot_materials",
+                new=AsyncMock(
+                    return_value=[
+                        SlotMaterial(
+                            ams_id=2,
+                            tray_id=0,
+                            global_tray_id=8,
+                            material_key="unmatched:85",
+                            remaining_grams=640.0,
+                            extruder=1,
+                            spool=SlotSpoolIdentity(
+                                brand="Devil Design",
+                                material="PLA",
+                                subtype="Basic",
+                                color_name="Orange",
+                                rgba="FEC600FF",
+                            ),
+                        ),
+                    ]
+                ),
+            ),
+        ):
+            result = await _call_endpoint(db)
+
+        assert result["slot_materials"][0]["spool"] == {
+            "brand": "Devil Design",
+            "material": "PLA",
+            "subtype": "Basic",
+            "color_name": "Orange",
+            "rgba": "FEC600FF",
+        }

+ 92 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -15,6 +15,7 @@ import {
   useFilamentMapping,
   useFilamentMapping,
 } from '../../hooks/useFilamentMapping';
 } from '../../hooks/useFilamentMapping';
 import { effectivePreferLowest } from '../../utils/amsHelpers';
 import { effectivePreferLowest } from '../../utils/amsHelpers';
+import { getColorName } from '../../utils/colors';
 import type { PrinterStatus } from '../../api/client';
 import type { PrinterStatus } from '../../api/client';
 
 
 // Helper to create a minimal printer status with AMS data
 // Helper to create a minimal printer status with AMS data
@@ -1445,3 +1446,94 @@ describe('filament type equivalence groups reach the matcher', () => {
     expect(item.status).toBe('mismatch');
     expect(item.status).toBe('mismatch');
   });
   });
 });
 });
+
+describe('buildLoadedFilaments — naming a slot after its bound spool', () => {
+  /**
+   * The printer cannot describe a third-party spool. Its tray record has no
+   * brand field, `tray_sub_brands` stays empty, and the colour hex gets
+   * resolved against Bambu's own catalogue — so a Devil Design PLA Basic
+   * Orange assigned in Bambuddy read back here as "PLA (Sunflower Yellow)",
+   * because Bambu sell a Sunflower Yellow at the same FEC600. The printer
+   * card names it correctly because it reads the assignment; this is that
+   * same identity, reaching the print dialog.
+   */
+  const devilDesign = {
+    brand: 'Devil Design',
+    material: 'PLA',
+    subtype: 'Basic',
+    color_name: 'Orange',
+    rgba: 'FEC600FF',
+  };
+
+  const c1 = createPrinterStatus([
+    {
+      id: 2,
+      tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FEC600FF', tray_info_idx: 'GFA00', tray_sub_brands: '' }],
+    },
+  ]);
+
+  it('names the slot after the spool instead of the catalogue colour', () => {
+    const [slot] = buildLoadedFilaments(c1, new Map([[8, devilDesign]]));
+
+    expect(slot.spoolName).toBe('Devil Design PLA Basic');
+    expect(slot.colorName).toBe('Orange');
+  });
+
+  it('leaves the matching inputs on telemetry', () => {
+    // Auto-assign and the colour-mismatch test have to keep agreeing with the
+    // dispatcher, which only ever sees what the printer reports.
+    const [withSpool] = buildLoadedFilaments(c1, new Map([[8, devilDesign]]));
+    const [without] = buildLoadedFilaments(c1);
+
+    expect(withSpool.type).toBe(without.type);
+    expect(withSpool.color).toBe(without.color);
+    expect(withSpool.trayInfoIdx).toBe(without.trayInfoIdx);
+    expect(withSpool.traySubBrands).toBe(without.traySubBrands);
+  });
+
+  it('falls back per field, not all or nothing', () => {
+    // A spool with no colour name still gets its brand and subtype from the
+    // binding while the colour comes from the catalogue lookup as before.
+    const [slot] = buildLoadedFilaments(
+      createPrinterStatus([
+        { id: 0, tray: [{ id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_sub_brands: 'PLA Silk+' }] },
+      ]),
+      new Map([[2, { brand: 'Bambu Lab', material: 'PLA', subtype: 'Silk+', color_name: null, rgba: '5F6367FF' }]]),
+    );
+
+    expect(slot.spoolName).toBe('Bambu Lab PLA Silk+');
+    expect(slot.colorName).toBe(getColorName('#5F6367FF', 'PLA Silk+'));
+  });
+
+  it('describes an unbound slot exactly as it did before', () => {
+    // Only the bound slot is renamed — an empty map must not blank the rest.
+    const [bound, unbound] = buildLoadedFilaments(
+      createPrinterStatus([
+        {
+          id: 0,
+          tray: [
+            { id: 0, tray_type: 'PLA', tray_color: 'FEC600FF' },
+            { id: 1, tray_type: 'PLA', tray_color: '00FF00FF', tray_sub_brands: 'PLA Matte' },
+          ],
+        },
+      ]),
+      new Map([[0, devilDesign]]),
+    );
+
+    expect(bound.spoolName).toBe('Devil Design PLA Basic');
+    expect(unbound.spoolName).toBeUndefined();
+    expect(unbound.colorName).toBe(getColorName('#00FF00FF', 'PLA Matte'));
+  });
+
+  it('names the external spool holder too', () => {
+    // Its global tray id IS the tray id (254 / 255) on both sides.
+    const [slot] = buildLoadedFilaments(
+      createPrinterStatus([], [{ id: 254, tray_type: 'PLA', tray_color: 'FEC600FF' }]),
+      new Map([[254, devilDesign]]),
+    );
+
+    expect(slot.isExternal).toBe(true);
+    expect(slot.spoolName).toBe('Devil Design PLA Basic');
+    expect(slot.colorName).toBe('Orange');
+  });
+});

+ 14 - 0
frontend/src/api/client.ts

@@ -3681,6 +3681,20 @@ export interface SlotMaterial {
   remaining_g: number;
   remaining_g: number;
   /** 0 = right / single nozzle, 1 = left. */
   /** 0 = right / single nozzle, 1 = left. */
   extruder: number;
   extruder: number;
+  /** How the bound spool should be *named* — display only, never matched on.
+   *  The printer has no brand field and reports no sub-brand for a
+   *  third-party spool, so this is the only place a slot's real identity
+   *  exists. Null when the binding resolves to a spool we cannot describe. */
+  spool?: SlotSpoolIdentity | null;
+}
+
+/** Display identity of an inventory-bound slot. See `SlotMaterial.spool`. */
+export interface SlotSpoolIdentity {
+  brand: string | null;
+  material: string | null;
+  subtype: string | null;
+  color_name: string | null;
+  rgba: string | null;
 }
 }
 
 
 export interface InventoryRemainResponse {
 export interface InventoryRemainResponse {

+ 32 - 2
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
+import type { SlotSpoolIdentity } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
 import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
 import { disambiguateColorNames, getColorName } from '../../utils/colors';
 import { disambiguateColorNames, getColorName } from '../../utils/colors';
@@ -112,6 +113,14 @@ export function FilamentMapping({
     queryFn: () => api.getInventoryRemain(printerId),
     queryFn: () => api.getInventoryRemain(printerId),
     enabled: !!printerId,
     enabled: !!printerId,
     staleTime: 30 * 1000,
     staleTime: 30 * 1000,
+    // Fresh on every open, cached while open. This payload now names the
+    // slots, and a spool assigned moments ago would otherwise keep its old
+    // name for the rest of the stale window. Doing it here rather than
+    // invalidating the key from each of the eighteen places a binding or a
+    // spool can change: half of those are internal-inventory paths and half
+    // are Spoolman ones, and covering some of them would make freshness
+    // depend on which inventory mode you run.
+    refetchOnMount: 'always',
   });
   });
   const inventoryByTrayId = useMemo(() => {
   const inventoryByTrayId = useMemo(() => {
     if (!inventoryRemain?.inventory_remain_g) return undefined;
     if (!inventoryRemain?.inventory_remain_g) return undefined;
@@ -122,13 +131,34 @@ export function FilamentMapping({
     });
     });
     return map;
     return map;
   }, [inventoryRemain]);
   }, [inventoryRemain]);
+  // The other half of the same payload: what Bambuddy has bound to each slot,
+  // so a slot reads as the spool the operator assigned rather than as whatever
+  // the printer can say about it. A third-party spool reports no sub-brand at
+  // all and its colour hex resolves against Bambu's catalogue, so without this
+  // the dialog named slots differently from the printer card.
+  const slotSpools = useMemo(() => {
+    const slots = inventoryRemain?.slot_materials;
+    if (!slots?.length) return undefined;
+    const map = new Map<number, SlotSpoolIdentity>();
+    slots.forEach((slot) => {
+      if (slot.spool) map.set(slot.global_tray_id, slot.spool);
+    });
+    return map.size > 0 ? map : undefined;
+  }, [inventoryRemain]);
   const gatedPreferLowest = effectivePreferLowest(
   const gatedPreferLowest = effectivePreferLowest(
     settings?.prefer_lowest_filament,
     settings?.prefer_lowest_filament,
     printerStatus?.ams_filament_backup,
     printerStatus?.ams_filament_backup,
   );
   );
 
 
   const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
   const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
-    useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId);
+    useFilamentMapping(
+      filamentReqs,
+      printerStatus,
+      manualMappings,
+      gatedPreferLowest,
+      inventoryByTrayId,
+      slotSpools,
+    );
 
 
   // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
   // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
   // shared hook the model-mode FilamentOverride uses so both panels render
   // shared hook the model-mode FilamentOverride uses so both panels render
@@ -508,7 +538,7 @@ export function FilamentMapping({
                       const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
                       const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
                       return (
                       return (
                         <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                         <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                          {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
+                          {f.label}: {f.spoolName || f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
                         </option>
                         </option>
                       );
                       );
                   })}
                   })}

+ 4 - 0
frontend/src/components/PrintModal/index.tsx

@@ -352,6 +352,10 @@ export function PrintModal({
       queryKey: ['printer-inventory-remain', printerId],
       queryKey: ['printer-inventory-remain', printerId],
       queryFn: () => api.getInventoryRemain(printerId),
       queryFn: () => api.getInventoryRemain(printerId),
       staleTime: 30 * 1000,
       staleTime: 30 * 1000,
+      // Same key, same reason as FilamentMapping's copy — see the note there.
+      // Concurrent mounts dedupe, so opening the dialog costs one fetch per
+      // printer however many plate panels are on screen.
+      refetchOnMount: 'always',
       enabled: selectedPrinters.length > 0,
       enabled: selectedPrinters.length > 0,
     })),
     })),
   });
   });

+ 51 - 9
frontend/src/hooks/useFilamentMapping.ts

@@ -12,13 +12,42 @@ import {
   preferLowestSortKey,
   preferLowestSortKey,
   compareSortKeys,
   compareSortKeys,
 } from '../utils/amsHelpers';
 } from '../utils/amsHelpers';
-import type { PrinterStatus } from '../api/client';
+import type { PrinterStatus, SlotSpoolIdentity } from '../api/client';
+
+/** Global-tray-id → the identity of the spool Bambuddy has bound to that slot. */
+export type SlotSpoolIdentities = Map<number, SlotSpoolIdentity>;
+
+/**
+ * Name a slot after the spool bound to it, the way the printer card does.
+ *
+ * Telemetry can only ever say "PLA" plus a colour hex for anything that isn't
+ * a Bambu spool: the tray record has no brand field, `tray_sub_brands` stays
+ * empty, and the hex gets resolved against Bambu's own colour catalogue. So a
+ * Devil Design PLA Basic Orange the operator assigned in Bambuddy read back as
+ * "PLA (Sunflower Yellow)" here while the printer card named it correctly.
+ *
+ * Returns null when the slot has no binding or the binding says nothing
+ * useful, and the caller keeps the telemetry description it had before.
+ */
+function spoolDisplayName(identity: SlotSpoolIdentity | undefined): string | null {
+  if (!identity) return null;
+  // Same order the hover card prints: brand, then material, then subtype.
+  const parts = [identity.brand, identity.material, identity.subtype].filter(Boolean);
+  return parts.length > 0 ? parts.join(' ') : null;
+}
 
 
 /**
 /**
  * Build loaded filaments list from printer status (non-hook version).
  * Build loaded filaments list from printer status (non-hook version).
  * Extracts filaments from all AMS units (regular and HT) and external spool.
  * Extracts filaments from all AMS units (regular and HT) and external spool.
+ *
+ * `slotSpools` is optional and display-only: it renames slots after their
+ * bound spool without touching `type`, `color` or `trayInfoIdx`, so matching
+ * and the dispatcher keep drawing on the printer's own telemetry.
  */
  */
-export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
+export function buildLoadedFilaments(
+  printerStatus: PrinterStatus | undefined,
+  slotSpools?: SlotSpoolIdentities,
+): LoadedFilament[] {
   const filaments: LoadedFilament[] = [];
   const filaments: LoadedFilament[] = [];
   const amsExtruderMap = printerStatus?.ams_extruder_map;
   const amsExtruderMap = printerStatus?.ams_extruder_map;
   // Dual-nozzle detection. The backend always emits a 2-entry nozzles array
   // Dual-nozzle detection. The backend always emits a 2-entry nozzles array
@@ -41,18 +70,21 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
     amsUnit.tray.forEach((tray) => {
     amsUnit.tray.forEach((tray) => {
       if (tray.tray_type) {
       if (tray.tray_type) {
         const color = normalizeColor(tray.tray_color);
         const color = normalizeColor(tray.tray_color);
+        const globalTrayId = getGlobalTrayId(amsUnit.id, tray.id, false);
+        const identity = slotSpools?.get(globalTrayId);
         filaments.push({
         filaments.push({
           type: tray.tray_type,
           type: tray.tray_type,
           color,
           color,
-          colorName: getColorName(color, tray.tray_sub_brands),
+          colorName: identity?.color_name?.trim() || getColorName(color, tray.tray_sub_brands),
           amsId: amsUnit.id,
           amsId: amsUnit.id,
           trayId: tray.id,
           trayId: tray.id,
           isHt,
           isHt,
           isExternal: false,
           isExternal: false,
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-          globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          globalTrayId,
           trayInfoIdx: tray.tray_info_idx || '',
           trayInfoIdx: tray.tray_info_idx || '',
           traySubBrands: tray.tray_sub_brands || '',
           traySubBrands: tray.tray_sub_brands || '',
+          spoolName: spoolDisplayName(identity) ?? undefined,
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
           remain: tray.remain ?? -1,
           remain: tray.remain ?? -1,
         });
         });
@@ -66,10 +98,13 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       const color = normalizeColor(extTray.tray_color);
       const color = normalizeColor(extTray.tray_color);
       const trayId = extTray.id ?? 254;
       const trayId = extTray.id ?? 254;
       const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
       const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
+      // The external holder's global tray id IS the tray id (254 / 255), which
+      // is how the backend keys it too — see `_ams_key_to_global`.
+      const identity = slotSpools?.get(trayId);
       filaments.push({
       filaments.push({
         type: extTray.tray_type,
         type: extTray.tray_type,
         color,
         color,
-        colorName: getColorName(color, extTray.tray_sub_brands),
+        colorName: identity?.color_name?.trim() || getColorName(color, extTray.tray_sub_brands),
         amsId: -1,
         amsId: -1,
         trayId: trayId - 254,
         trayId: trayId - 254,
         isHt: false,
         isHt: false,
@@ -78,6 +113,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         globalTrayId: trayId,
         globalTrayId: trayId,
         trayInfoIdx: extTray.tray_info_idx || '',
         trayInfoIdx: extTray.tray_info_idx || '',
         traySubBrands: extTray.tray_sub_brands || '',
         traySubBrands: extTray.tray_sub_brands || '',
+        spoolName: spoolDisplayName(identity) ?? undefined,
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
         remain: extTray.remain ?? -1,
         remain: extTray.remain ?? -1,
       });
       });
@@ -140,6 +176,10 @@ export interface LoadedFilament {
   trayInfoIdx?: string;
   trayInfoIdx?: string;
   /** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
   /** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
   traySubBrands?: string;
   traySubBrands?: string;
+  /** "Devil Design PLA Basic" — the spool Bambuddy has bound to this slot,
+   *  when there is one. Display only; prefer it over `traySubBrands`, which
+   *  the printer leaves empty for everything that isn't a Bambu spool. */
+  spoolName?: string;
   /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
   /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
   extruderId?: number;
   extruderId?: number;
   /** Remaining filament percentage (0-100), -1 = unknown */
   /** Remaining filament percentage (0-100), -1 = unknown */
@@ -206,11 +246,12 @@ interface UseFilamentMappingResult {
  * Extracts filaments from all AMS units (regular and HT) and external spool.
  * Extracts filaments from all AMS units (regular and HT) and external spool.
  */
  */
 export function useLoadedFilaments(
 export function useLoadedFilaments(
-  printerStatus: PrinterStatus | undefined
+  printerStatus: PrinterStatus | undefined,
+  slotSpools?: SlotSpoolIdentities,
 ): LoadedFilament[] {
 ): LoadedFilament[] {
   return useMemo(() => {
   return useMemo(() => {
-    return buildLoadedFilaments(printerStatus);
-  }, [printerStatus]);
+    return buildLoadedFilaments(printerStatus, slotSpools);
+  }, [printerStatus, slotSpools]);
 }
 }
 
 
 /**
 /**
@@ -456,8 +497,9 @@ export function useFilamentMapping(
   manualMappings: Record<number, number>,
   manualMappings: Record<number, number>,
   preferLowest?: boolean,
   preferLowest?: boolean,
   inventoryByTrayId?: Map<number, number>,
   inventoryByTrayId?: Map<number, number>,
+  slotSpools?: SlotSpoolIdentities,
 ): UseFilamentMappingResult {
 ): UseFilamentMappingResult {
-  const loadedFilaments = useLoadedFilaments(printerStatus);
+  const loadedFilaments = useLoadedFilaments(printerStatus, slotSpools);
 
 
   // FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
   // FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
   // doesn't apply when it's installed (#1162).
   // doesn't apply when it's installed (#1162).

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-DkJ3AJ3x.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CLXSCni4.js"></script>
+    <script type="module" crossorigin src="/assets/index-DkJ3AJ3x.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-q2IPtdZB.css">
     <link rel="stylesheet" crossorigin href="/assets/index-q2IPtdZB.css">
   </head>
   </head>
   <body>
   <body>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff