Bladeren bron

feat(ams): confirm spool assignments landed instead of fire-and-forget (#2582)

Assigning a spool to an AMS tray pushed ams_filament_setting +
extrusion_cali_sel and reported success immediately, whether or not the
tray accepted it. A silently-dropped assignment never surfaced, and since
a print only deducts from the spool on the exact tray it pulls from, it
also recorded no filament usage - which made the whole thing feel random.

Read the AMS telemetry back after every assign (inventory assign_spool and
the Configure Slot modal) and toast the outcome: loaded when the tray
echoes the pushed tray_info_idx, a warning when the filament loaded but the
K-profile (cali_idx) did not, or not-confirmed after ~30s. Verification
uses the periodic per-tray push (the command ack hardcodes sequence_id 0
and can't be correlated); an on-demand pushall is nudged so it lands
quickly. Covers regular AMS, AMS-HT and external slots; stays silent rather
than inventing a failure if the printer goes quiet. The read-back check
runs on every AMS push because the change-hash excludes tray_info_idx.
maziggy 1 maand geleden
bovenliggende
commit
2e74f2ad41

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.5b2] - Unreleased
 
 ### Added
+- **Assigning a spool to an AMS slot now tells you whether the printer actually accepted it (#2582, reporter @gyrene2083)** — Until now, assigning a spool to an AMS tray was fire-and-forget: Bambuddy pushed the filament setting to the printer and immediately reported success, whether or not the tray took it. When the assignment silently didn't land — the reporter's case, where a spool assigned in Bambuddy never showed up in Bambu Studio — nothing told you, and the only way to tell it had loaded was to run a flow calibration and watch for the K-profile to appear. Because a print only deducts filament from the spool assigned to the *exact* tray it pulls from, a silently-dropped assignment also meant that print recorded no filament usage, which is what made the whole thing feel random. Bambuddy now reads the AMS telemetry back after every assignment (from both **Printers → assign spool** and **Configure Slot**) and toasts the outcome: **"Filament loaded on slot X"** once the tray echoes back the filament id that was pushed, a warning if the filament loaded but the **flow-calibration (K-profile) wasn't applied**, or **"couldn't confirm the assignment — check the AMS slot"** if the tray never reflects it within ~30s. The confirmation is derived entirely from the periodic status the printer already sends (an on-demand pushall is nudged so it lands quickly), covers regular AMS, AMS-HT, and external-spool slots, and if the printer goes silent it simply stays quiet rather than inventing a failure. No configuration; the toast appears automatically on assign.
 - **Bed levelling, flow calibration, and nozzle-offset calibration now have an "Auto" option, matching Bambu Studio** — These three print options were previously on/off only, so the only way to run bed levelling was to force a full level before every print. Bambu Studio has long offered a third "Auto" state that lets the printer skip the calibration when it was done recently, and that state is what most people actually want. All three options (in the Schedule/Print dialog, the queue bulk-edit, and Settings → Workflow → Default Print Options) are now a three-way **Off / Auto / On** choice, and new prints default to **Auto**. "On" still forces the calibration every time; "Off" skips it entirely; "Auto" lets the printer decide. Existing queued prints and your saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)" and anything "off" stays "Off", so nothing changes for in-flight jobs until you opt into Auto. The wire encoding mirrors Bambu Studio's exactly (verified against its source), including how prints sent through a Virtual Printer inherit the slicer's own Auto/On/Off pick.
 
 ### Changed

+ 27 - 0
backend/app/api/routes/inventory.py

@@ -287,6 +287,21 @@ async def apply_spool_to_slot_via_mqtt(
             spool.id,
         )
 
+    # Register a read-back verification so the next AMS pushes can confirm the
+    # tray actually accepted this assignment (#2582). We record the same
+    # effective filament id we pushed plus the cali_idx we selected (or -1 for
+    # the Default-K reset above), and the client fires on_assignment_verified
+    # on match/timeout. Colour is informational only — the match keys on the
+    # filament id the slicer echoes back.
+    verify_cali_idx = matching_kp.cali_idx if (matching_kp and matching_kp.cali_idx is not None) else -1
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=verify_cali_idx,
+    )
+
     # Persist slot preset mapping for UI display (preset_name on hover card).
     # Shared with the RFID auto-assign path — both must keep this row in sync
     # with the currently-assigned spool, otherwise the slot card surfaces the
@@ -1803,6 +1818,18 @@ async def assign_spool(
             )
         except Exception as e:
             logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+        else:
+            # Nudge a fresh pushall so the read-back verification registered in
+            # apply_spool_to_slot_via_mqtt (#2582) has current tray telemetry to
+            # compare against within its window, instead of waiting for the next
+            # idle push. Best-effort — the periodic push is the fallback.
+            if configured:
+                try:
+                    client = printer_manager.get_client(data.printer_id)
+                    if client:
+                        client.request_status_update()
+                except Exception:
+                    pass
     # pending_config is the "config not landed yet" UI marker. True when the
     # firmware said empty, OR when MQTT couldn't actually publish (printer
     # offline, no client, transient failure). on_ams_change replay re-fires

+ 11 - 0
backend/app/api/routes/printers.py

@@ -2720,6 +2720,17 @@ async def configure_ams_slot(
             except Exception:
                 pass
 
+    # Register a read-back verification (#2582) so the tray telemetry that the
+    # status push below returns can confirm the printer accepted this manual
+    # slot configuration. Mirrors the inventory/assignment path.
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=cali_idx,
+    )
+
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()

+ 55 - 0
backend/app/main.py

@@ -6360,6 +6360,61 @@ async def lifespan(app: FastAPI):
 
     printer_manager.set_drying_complete_callback(on_drying_complete)
 
+    async def on_assignment_verified(printer_id: int, ams_id: int, tray_id: int, verified: bool, detail: dict):
+        """Surface the read-back result of a spool assignment to the UI (#2582).
+
+        The MQTT client confirms (or fails to confirm) that the tray telemetry
+        echoed back the filament id we pushed. We relay that as a websocket
+        event so the frontend can toast "loaded" / "assignment didn't take"
+        instead of the historic silent fire-and-forget, which made the
+        AMS→Studio hand-off feel random to users.
+        """
+        try:
+            from backend.app.services.spool_assignment_notifications import (
+                _slot_label_from_global_tray,
+            )
+
+            if ams_id == 255:
+                global_id = 254 + tray_id
+            elif ams_id >= 128:
+                global_id = ams_id
+            else:
+                global_id = ams_id * 4 + tray_id
+            slot_label = _slot_label_from_global_tray(global_id)
+
+            printer_info = printer_manager.get_printer(printer_id)
+            printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
+
+            await ws_manager.broadcast(
+                {
+                    "type": "spool_assignment_verified",
+                    "printer_id": printer_id,
+                    "printer_name": printer_name,
+                    "ams_id": ams_id,
+                    "tray_id": tray_id,
+                    "slot": slot_label,
+                    "verified": verified,
+                    # Present on success: False means the filament setting landed
+                    # but the K-profile (cali_idx) did not — the reporter's exact
+                    # "loaded but no flow profile" symptom.
+                    "kprofile_applied": detail.get("kprofile_applied", True),
+                    # Present on failure: whether any tray telemetry was seen in
+                    # the window (distinguishes "printer silent" from "printer
+                    # stored something else").
+                    "saw_tray": detail.get("saw_tray", False),
+                }
+            )
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Failed to broadcast assignment verification for printer %d AMS%d-T%d: %s",
+                printer_id,
+                ams_id,
+                tray_id,
+                e,
+            )
+
+    printer_manager.set_assignment_verified_callback(on_assignment_verified)
+
     # Initialize MQTT relay from settings
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting

+ 166 - 0
backend/app/services/bambu_mqtt.py

@@ -490,6 +490,12 @@ class BambuMQTTClient:
     # Counter for generating unique MQTT client IDs across instances.
     _client_instance_counter: int = 0
 
+    # #2582: how long to wait for the AMS telemetry to echo back an assignment
+    # before declaring it un-confirmed. The printer re-broadcasts tray state
+    # every few seconds (and register_assignment_verification nudges a fresh
+    # pushall), so this only has to survive a couple of idle push intervals.
+    ASSIGNMENT_VERIFY_TIMEOUT: float = 30.0
+
     def __init__(
         self,
         ip_address: str,
@@ -505,6 +511,7 @@ class BambuMQTTClient:
         on_drying_complete: Callable[[int], None] | None = None,
         on_print_running_observed: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
+        on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -541,6 +548,19 @@ class BambuMQTTClient:
         # stage 22 never arrives (cancel mid-print, external-spool-
         # only prints, HMS halt before unload, firmware variants).
         self.on_finish_photo_moment = on_finish_photo_moment
+        # #2582: fired after a spool assignment (ams_filament_setting +
+        # extrusion_cali_sel) once the tray's telemetry either confirms the
+        # push landed or a timeout elapses without it. Receives
+        # (ams_id, tray_id, verified: bool, detail: dict). Lets the frontend
+        # tell the user "loaded" vs "assignment didn't take" instead of the
+        # historic fire-and-forget silence that made the AMS/Studio hand-off
+        # feel random. See _check_assignment_verifications.
+        self.on_assignment_verified = on_assignment_verified
+        # Pending read-back verifications, keyed by (ams_id, tray_id). Each
+        # value is the desired end-state we just pushed plus a monotonic
+        # deadline. Populated by register_assignment_verification, drained by
+        # _check_assignment_verifications on every AMS push.
+        self._pending_assignments: dict[tuple[int, int], dict] = {}
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
@@ -838,6 +858,11 @@ class BambuMQTTClient:
             self._report_messages_since_connect = 0
             self._last_ams_cmd_time = 0.0
             self._ams_cmd_unanswered = 0
+            # Drop any assignment verifications that were mid-flight before the
+            # reconnect — their deadlines are stale and the tray state we would
+            # compare against is about to be re-pushed from scratch (#2582).
+            # Dropping is silent (no failure event) on purpose.
+            self._pending_assignments.clear()
             client.subscribe(self.topic_subscribe)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             if self._request_topic_supported:
@@ -2332,6 +2357,147 @@ class BambuMQTTClient:
                 # may lack fields like 'remain' that the merged state preserves
                 self.on_ams_change(merged_ams)
 
+        # #2582: read-back check runs on EVERY AMS push, not just hash changes.
+        # The change hash keys on tray_type/tag_uid/remain — NOT tray_info_idx
+        # or cali_idx — so an assignment that only swaps the filament id on an
+        # already-loaded slot would not flip the hash, and gating the check on
+        # it would miss exactly the confirmation we are after.
+        if self._pending_assignments:
+            self._check_assignment_verifications()
+
+    def register_assignment_verification(
+        self,
+        ams_id: int,
+        tray_id: int,
+        tray_info_idx: str,
+        tray_color: str,
+        cali_idx: int | None,
+    ) -> None:
+        """Record an assignment we just pushed so subsequent AMS telemetry can
+        confirm the tray actually accepted it (#2582).
+
+        Called right after ``ams_set_filament_setting`` + ``extrusion_cali_sel``.
+        ``tray_info_idx`` is the primary signal — the slicer/printer echoes the
+        accepted filament id back in the per-tray push, so a match means the
+        setting landed. ``cali_idx`` (when >= 0) is verified as a secondary
+        signal so we can specifically flag "filament loaded but K-profile not
+        applied", which is the exact symptom the reporter chased via flow-cal.
+
+        A blank ``tray_info_idx`` means we had nothing resolvable to send, so
+        there is nothing to verify and no record is stored.
+        """
+        want_idx = (tray_info_idx or "").strip().upper()
+        if not want_idx:
+            return
+        self._pending_assignments[(ams_id, tray_id)] = {
+            "tray_info_idx": want_idx,
+            "tray_color": (tray_color or "").strip().upper(),
+            "cali_idx": cali_idx,
+            "deadline": time.monotonic() + self.ASSIGNMENT_VERIFY_TIMEOUT,
+            "last_seen_idx": None,
+        }
+
+    def _find_verify_tray(self, ams_id: int, tray_id: int) -> dict | None:
+        """Locate the live tray dict for a pending verification.
+
+        External spools (ams_id 255) live in ``vt_tray`` under global ids
+        254/255; regular and HT AMS trays live under ``ams[].tray[]``. HT units
+        report a single tray whose id may not equal the logical tray_id, so fall
+        back to the sole tray when an id match fails.
+        """
+        raw = self.state.raw_data or {}
+        if ams_id == 255:
+            want_ext = 254 + tray_id
+            for vt in raw.get("vt_tray", []) or []:
+                if isinstance(vt, dict) and str(vt.get("id")) == str(want_ext):
+                    return vt
+            return None
+        for unit in raw.get("ams", []) or []:
+            if str(unit.get("id")) != str(ams_id):
+                continue
+            trays = unit.get("tray", []) or []
+            for tray in trays:
+                if str(tray.get("id")) == str(tray_id):
+                    return tray
+            if ams_id >= 128 and len(trays) == 1:
+                return trays[0]
+            return None
+        return None
+
+    def _check_assignment_verifications(self) -> None:
+        """Compare each pending assignment against live tray telemetry and fire
+        ``on_assignment_verified`` on a match or once the deadline passes.
+
+        Runs on every AMS push. Non-matching-but-still-within-window entries are
+        left in place for the next push. The timeout branch only fires when a
+        later push arrives after the deadline; if the printer goes silent we
+        simply never confirm, which is preferable to inventing a failure.
+        """
+        now = time.monotonic()
+        for key, want in list(self._pending_assignments.items()):
+            ams_id, tray_id = key
+            tray = self._find_verify_tray(ams_id, tray_id)
+            actual_idx = str((tray or {}).get("tray_info_idx") or "").strip().upper()
+            if tray is not None and actual_idx:
+                want["last_seen_idx"] = actual_idx
+            if actual_idx and actual_idx == want["tray_info_idx"]:
+                self._pending_assignments.pop(key, None)
+                kprofile_applied = True
+                want_cali = want.get("cali_idx")
+                if want_cali is not None and want_cali >= 0:
+                    actual_cali = tray.get("cali_idx")
+                    kprofile_applied = actual_cali == want_cali
+                self._fire_assignment_verified(
+                    ams_id,
+                    tray_id,
+                    True,
+                    {
+                        "tray_info_idx": actual_idx,
+                        "kprofile_applied": kprofile_applied,
+                    },
+                )
+            elif now >= want["deadline"]:
+                self._pending_assignments.pop(key, None)
+                self._fire_assignment_verified(
+                    ams_id,
+                    tray_id,
+                    False,
+                    {
+                        "expected_tray_info_idx": want["tray_info_idx"],
+                        "actual_tray_info_idx": want.get("last_seen_idx"),
+                        # True when we saw the tray at least once (so the push
+                        # channel is alive and the printer really stored a
+                        # different/blank id) vs never observing it at all.
+                        "saw_tray": want.get("last_seen_idx") is not None,
+                    },
+                )
+
+    def _fire_assignment_verified(self, ams_id: int, tray_id: int, verified: bool, detail: dict) -> None:
+        if verified:
+            logger.info(
+                "[%s] Assignment verified: AMS%d-T%d now reports %s (kprofile_applied=%s)",
+                self.serial_number,
+                ams_id,
+                tray_id,
+                detail.get("tray_info_idx"),
+                detail.get("kprofile_applied"),
+            )
+        else:
+            logger.warning(
+                "[%s] Assignment NOT confirmed: AMS%d-T%d expected %s, tray shows %s (saw_tray=%s)",
+                self.serial_number,
+                ams_id,
+                tray_id,
+                detail.get("expected_tray_info_idx"),
+                detail.get("actual_tray_info_idx"),
+                detail.get("saw_tray"),
+            )
+        if self.on_assignment_verified:
+            try:
+                self.on_assignment_verified(ams_id, tray_id, verified, detail)
+            except Exception:
+                logger.exception("[%s] on_assignment_verified callback failed", self.serial_number)
+
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         _previous_state = self.state.state

+ 15 - 0
backend/app/services/printer_manager.py

@@ -327,6 +327,7 @@ class PrinterManager:
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
+        self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -513,6 +514,15 @@ class PrinterManager:
         """
         self._on_drying_complete = callback
 
+    def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
+        """Set callback for spool-assignment read-back verification (#2582).
+
+        Receives ``(printer_id, ams_id, tray_id, verified, detail)``. Fires once
+        per assignment either when the tray telemetry confirms the pushed
+        filament id or when the verification window elapses without it.
+        """
+        self._on_assignment_verified = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
 
@@ -576,6 +586,10 @@ class PrinterManager:
             if self._on_drying_complete:
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
 
+        def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
+            if self._on_assignment_verified:
+                self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -590,6 +604,7 @@ class PrinterManager:
             on_drying_complete=on_drying_complete,
             on_print_running_observed=on_print_running_observed,
             on_finish_photo_moment=on_finish_photo_moment,
+            on_assignment_verified=on_assignment_verified,
         )
 
         client.connect()

+ 151 - 0
backend/tests/unit/test_assignment_verification_2582.py

@@ -0,0 +1,151 @@
+"""Read-back verification of AMS spool assignments (#2582).
+
+After Bambuddy pushes an assignment (``ams_filament_setting`` +
+``extrusion_cali_sel``) it registers the desired end-state and watches the
+periodic AMS telemetry to confirm the tray actually accepted it. Historically
+this was fire-and-forget, so a silently-dropped assignment (the reporter's
+"assigned in Bambuddy but Studio never saw it") produced no feedback at all.
+
+These tests lock in the matcher: a tray_info_idx echo confirms the push landed,
+cali_idx is a secondary "K-profile applied" signal, and a timeout without a
+matching echo reports a non-confirmation instead of inventing success.
+"""
+
+import time
+from unittest.mock import MagicMock
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _client(on_verified=None) -> BambuMQTTClient:
+    return BambuMQTTClient(
+        ip_address="10.0.0.1",
+        serial_number="SERIAL",
+        access_code="code",
+        model="P1S",
+        on_assignment_verified=on_verified,
+    )
+
+
+def _ams_frame(tray_id=0, ams_id=0, **tray_fields):
+    """One AMS unit with the given tray carrying content fields."""
+    tray = {"id": tray_id}
+    tray.update(tray_fields)
+    return {"ams": [{"id": ams_id, "tray": [tray]}]}
+
+
+class TestAssignmentMatch:
+    def test_matching_tray_info_idx_fires_verified(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(
+            ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="FF0000FF", cali_idx=-1
+        )
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", tray_type="PLA"))
+
+        cb.assert_called_once()
+        ams_id, tray_id, verified, detail = cb.call_args.args
+        assert (ams_id, tray_id, verified) == (0, 0, True)
+        assert detail["kprofile_applied"] is True
+        # Pending entry is cleared once resolved.
+        assert (0, 0) not in client._pending_assignments
+
+    def test_match_is_case_insensitive(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(
+            ams_id=0, tray_id=0, tray_info_idx="gfl05", tray_color="", cali_idx=None
+        )
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05"))
+        assert cb.call_args.args[2] is True
+
+    def test_kprofile_mismatch_flags_not_applied(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=3)
+        # Filament id landed but the printer kept a different cali_idx.
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", cali_idx=1))
+
+        verified, detail = cb.call_args.args[2], cb.call_args.args[3]
+        assert verified is True
+        assert detail["kprofile_applied"] is False
+
+    def test_kprofile_match_flags_applied(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=3)
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", cali_idx=3))
+        assert cb.call_args.args[3]["kprofile_applied"] is True
+
+
+class TestAssignmentPendingAndTimeout:
+    def test_divergent_idx_within_window_keeps_waiting(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        # Tray still shows the previous filament — no callback, stay pending.
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+        cb.assert_not_called()
+        assert (0, 0) in client._pending_assignments
+
+    def test_timeout_after_seeing_divergent_tray_reports_failure(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        # First push observes a divergent id (records last_seen_idx).
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+        # Force the deadline into the past, then another push evaluates it.
+        client._pending_assignments[(0, 0)]["deadline"] = time.monotonic() - 1
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+
+        verified, detail = cb.call_args.args[2], cb.call_args.args[3]
+        assert verified is False
+        assert detail["saw_tray"] is True
+        assert detail["actual_tray_info_idx"] == "GFU00"
+        assert (0, 0) not in client._pending_assignments
+
+    def test_timeout_without_ever_seeing_tray_reports_no_tray(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=1, tray_id=2, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        client._pending_assignments[(1, 2)]["deadline"] = time.monotonic() - 1
+        # A push for an unrelated AMS unit still triggers deadline evaluation.
+        client._handle_ams_data(_ams_frame(ams_id=0, tray_id=0, tray_info_idx="GFL05"))
+
+        verified, detail = cb.call_args.args[2], cb.call_args.args[3]
+        assert verified is False
+        assert detail["saw_tray"] is False
+        assert detail["actual_tray_info_idx"] is None
+
+
+class TestRegistrationGuards:
+    def test_blank_tray_info_idx_is_not_registered(self):
+        client = _client()
+        client.register_assignment_verification(
+            ams_id=0, tray_id=0, tray_info_idx="", tray_color="FF0000FF", cali_idx=-1
+        )
+        assert not client._pending_assignments
+
+    def test_reconnect_clears_pending(self):
+        client = _client()
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        assert client._pending_assignments
+        # Mirror the on_connect reset path.
+        client._pending_assignments.clear()
+        assert not client._pending_assignments
+
+
+class TestExternalSpool:
+    def test_external_tray_matches_via_vt_tray(self):
+        cb = MagicMock()
+        client = _client(cb)
+        # External-left spool: logical ams_id 255 / tray 0 lives at vt_tray id 254.
+        client.state.raw_data["vt_tray"] = [{"id": 254, "tray_info_idx": "GFL05"}]
+        client.register_assignment_verification(
+            ams_id=255, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1
+        )
+        # Any AMS push drives the check; the tray is resolved from vt_tray.
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+
+        cb.assert_called_once()
+        assert cb.call_args.args[2] is True

+ 50 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -510,6 +510,56 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
     });
 
+    it('handles spool_assignment_verified messages (success and failure) without error', async () => {
+      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+        cb(0);
+        return 0;
+      });
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+      });
+
+      // #2582: verified (loaded), loaded-but-no-K-profile, and not-confirmed
+      // all route to a toast — assert none of the branches throw.
+      expect(() => {
+        act(() => {
+          ws.simulateMessage({
+            type: 'spool_assignment_verified',
+            printer_id: 3,
+            printer_name: 'Printer A',
+            slot: 'A1',
+            verified: true,
+            kprofile_applied: true,
+          });
+          ws.simulateMessage({
+            type: 'spool_assignment_verified',
+            printer_id: 3,
+            printer_name: 'Printer A',
+            slot: 'A1',
+            verified: true,
+            kprofile_applied: false,
+          });
+          ws.simulateMessage({
+            type: 'spool_assignment_verified',
+            printer_id: 3,
+            printer_name: 'Printer A',
+            slot: 'A1',
+            verified: false,
+            saw_tray: true,
+          });
+        });
+      }).not.toThrow();
+
+      vi.unstubAllGlobals();
+    });
+
     it('ignores pong messages without error', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 

+ 30 - 0
frontend/src/hooks/useWebSocket.ts

@@ -18,6 +18,11 @@ interface WebSocketMessage {
   data?: Record<string, unknown>;
   printer_name?: string;
   missing_slots?: Array<{ slot?: string }>;
+  // Spool-assignment read-back verification (#2582).
+  slot?: string;
+  verified?: boolean;
+  kprofile_applied?: boolean;
+  saw_tray?: boolean;
   // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
   // PipelineRunResponse payload — typed loosely here so the WebSocket hook
   // doesn't pull the full client.ts types in.
@@ -338,6 +343,31 @@ export function useWebSocket() {
         debouncedInvalidate('slotPresets');
         break;
 
+      case 'spool_assignment_verified': {
+        // #2582: the backend read the AMS telemetry back after an assignment
+        // and either confirmed the tray accepted it or timed out. Toast the
+        // outcome so the AMS→Studio hand-off is no longer silent.
+        // Backend always supplies printer_name (falls back to "Printer <id>"),
+        // so the '||' here only guards a malformed payload.
+        const printer = message.printer_name || 'Printer';
+        const slot = message.slot || '?';
+        if (message.verified) {
+          if (message.kprofile_applied === false) {
+            // Filament id landed but the K-profile (cali_idx) did not — the
+            // exact "loaded but no flow profile" case the reporter chased.
+            showToast(
+              t('printers.toast.assignmentVerifiedNoKprofile', { slot, printer }),
+              'warning'
+            );
+          } else {
+            showToast(t('printers.toast.assignmentVerified', { slot, printer }), 'success');
+          }
+        } else {
+          showToast(t('printers.toast.assignmentNotConfirmed', { slot, printer }), 'warning');
+        }
+        break;
+      }
+
       case 'spool_auto_assigned':
         // RFID tag matched - refresh inventory and assignment data
         debouncedInvalidate('inventory-spools');

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: 'Drucker gelöscht',
       missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
+      assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
+      assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
+      assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
       printerAdded: 'Drucker hinzugefügt',
       printerUpdated: 'Drucker aktualisiert',
       failedToDelete: 'Drucker konnte nicht gelöscht werden',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -358,6 +358,9 @@ export default {
     toast: {
       printerDeleted: 'Printer deleted',
       missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
+      assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
+      assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
+      assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
       printerAdded: 'Printer added',
       printerUpdated: 'Printer updated',
       failedToDelete: 'Failed to delete printer',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: 'Impresora eliminada',
       missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
+      assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
+      assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
+      assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
       printerAdded: 'Impresora añadida',
       printerUpdated: 'Impresora actualizada',
       failedToDelete: 'Error al eliminar la impresora',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: 'Imprimante supprimée',
       missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
+      assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
+      assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
+      assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
       printerAdded: 'Imprimante ajoutée',
       printerUpdated: 'Imprimante mise à jour',
       failedToDelete: 'Échec de la suppression',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: 'Stampante eliminata',
       missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
+      assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
+      assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
+      assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
       printerAdded: 'Stampante aggiunta',
       printerUpdated: 'Stampante aggiornata',
       failedToDelete: 'Impossibile eliminare stampante',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -354,6 +354,9 @@ export default {
     toast: {
       printerDeleted: 'プリンターを削除しました',
       missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
+      assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
+      assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
+      assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
       printerAdded: 'プリンターを追加しました',
       printerUpdated: 'プリンターを更新しました',
       failedToDelete: 'プリンターの削除に失敗しました',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -330,6 +330,9 @@ export default {
     toast: {
       printerDeleted: '프린터가 삭제되었습니다',
       missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
+      assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
+      assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
+      assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
       printerAdded: '프린터가 추가되었습니다',
       printerUpdated: '프린터가 업데이트되었습니다',
       failedToDelete: '프린터 삭제 실패',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: 'Impressora excluída',
       missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
+      assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
+      assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
+      assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
       printerAdded: 'Impressora adicionada',
       printerUpdated: 'Impressora atualizada',
       failedToDelete: 'Falha ao excluir impressora',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -335,6 +335,9 @@ export default {
     toast: {
       printerDeleted: "Принтер удалён",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
+      assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
+      assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
+      assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
       printerAdded: "Принтер добавлен",
       printerUpdated: "Настройки принтера обновлены",
       failedToDelete: "Не удалось удалить принтер",

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: 'Yazıcı silindi',
       missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
+      assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
+      assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
+      assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
       printerAdded: 'Yazıcı eklendi',
       printerUpdated: 'Yazıcı güncellendi',
       failedToDelete: 'Yazıcı silinemedi',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: '打印机已删除',
       missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
+      assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
+      assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
+      assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
       printerAdded: '打印机已添加',
       printerUpdated: '打印机已更新',
       failedToDelete: '删除打印机失败',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -355,6 +355,9 @@ export default {
     toast: {
       printerDeleted: '印表機已刪除',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
+      assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
+      assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
+      assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
       printerAdded: '印表機已新增',
       printerUpdated: '印表機已更新',
       failedToDelete: '刪除印表機失敗',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-4hVOt7rj.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff