Просмотр исходного кода

feat(ams): name the expected slot when a paused print hits an AMS runout (#2587)

The firmware's runout HMS text says "insert into the same AMS slot", which is
wrong under AMS Filament Backup: the firmware won't re-accept the depleted slot
and advances to the next compatible one. Bambuddy parsed print.ams.tray_now only
and dropped tray_tar/tray_pre, so the expected slot never reached the UI.

Capture tray_tar/tray_pre on PrinterState and, while paused, resolve them to
global tray IDs (expected_tray/previous_tray) on both the REST and WebSocket
status payloads via a shared resolver: single-AMS passthrough, multi-AMS
snow-mapping resolution, AMS-HT/external passthrough, and an honest null when the
slot can't be placed. The AMS graphic highlights the expected slot (amber) and
the ran-out slot (red); the HMS modal re-describes runout codes to name both,
falling back to "check the printer" when unresolved. Runout copy translated in
all 11 locales.

Reporter @Jostxxl confirmed tray_pre=1/tray_tar=2 during the pause (ran out in
Slot 2, printer expected Slot 3).
maziggy 1 месяц назад
Родитель
Сommit
e77e10896f

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


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

@@ -54,6 +54,7 @@ from backend.app.services.printer_manager import (
     drying_screen_only,
     get_derived_status_name,
     printer_manager,
+    resolve_expected_tray,
     resolve_plate_id,
     supports_chamber_heater,
     supports_chamber_temp,
@@ -766,6 +767,26 @@ async def get_printer_status(
         ams_mapping=ams_mapping,
         ams_extruder_map=ams_extruder_map,
         tray_now=tray_now,
+        # Runout guidance (#2587): resolve the firmware's target/previous slot to a
+        # global tray ID, but only while PAUSED — the moment the operator needs it.
+        expected_tray=(
+            resolve_expected_tray(
+                state.tray_tar,
+                [(u.id, u.is_ams_ht) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
+        previous_tray=(
+            resolve_expected_tray(
+                state.tray_pre,
+                [(u.id, u.is_ams_ht) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
         ams_status_main=state.ams_status_main,
         ams_status_sub=state.ams_status_sub,
         mc_print_sub_stage=state.mc_print_sub_stage,

+ 11 - 0
backend/app/schemas/printer.py

@@ -332,6 +332,17 @@ class PrinterStatus(BaseModel):
     fila_switch: FilaSwitchResponse | None = None
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
+    # Runout / filament-replacement guidance (#2587). Populated only while the
+    # print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
+    # AMS-HT, or 254 for external) so the frontend can highlight them with the same
+    # logic it uses for tray_now:
+    #   expected_tray = the slot the firmware now expects filament in (from tray_tar).
+    #                   None when idle, not paused, or the slot can't be resolved
+    #                   (multi-AMS ambiguity) — the UI then says "check the printer".
+    #   previous_tray = the slot loaded before the pause, i.e. the one that ran out
+    #                   (from tray_pre). None when unknown.
+    expected_tray: int | None = None
+    previous_tray: int | None = None
     # AMS status for filament change tracking
     # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
     ams_status_main: int = 0

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

@@ -321,6 +321,15 @@ class PrinterState:
     active_extruder: int = 0
     # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     tray_now: int = 255
+    # Firmware's target/previous tray as reported in print.ams (RAW, not globalised):
+    #   tray_tar = the slot the paused/loading print now expects
+    #   tray_pre = the slot that was loaded before (e.g. the one that ran out)
+    # For a single regular AMS these equal the global tray ID; for multi-AMS they
+    # are local slot IDs (0-3) that must be resolved against the mapping field, and
+    # for AMS-HT they are already global (128-135). 255 = none/idle, 254 = external.
+    # Surfaced during a runout PAUSE so the UI can name the expected slot (#2587).
+    tray_tar: int = 255
+    tray_pre: int = 255
     # Last valid tray_now (0-253) — survives unload (255) for usage tracking after print completes
     last_loaded_tray: int = -1
     # Pending load target - used to track what tray we're loading for H2D disambiguation
@@ -1763,6 +1772,34 @@ class BambuMQTTClient:
                     self.state.ams_status_sub,
                 )
 
+            # Parse tray_tar / tray_pre (RAW). These identify the slot the firmware
+            # now expects (tray_tar) and the slot loaded before (tray_pre) — the key
+            # signal for a runout PAUSE where AMS Filament Backup has advanced to the
+            # next compatible slot (#2587). Stored raw here; globalised at the API
+            # boundary because that resolution needs the AMS layout. On H2D/multi-AMS
+            # these are local slot numbers (0-3), not global IDs.
+            for _tk, _attr in (("tray_tar", "tray_tar"), ("tray_pre", "tray_pre")):
+                if _tk in ams_data:
+                    _raw = ams_data[_tk]
+                    if isinstance(_raw, str):
+                        try:
+                            _val = int(_raw)
+                        except ValueError:
+                            _val = 255
+                    else:
+                        _val = _raw if _raw is not None else 255
+                    prev = getattr(self.state, _attr)
+                    setattr(self.state, _attr, _val)
+                    # Log changes only while paused — the moment the operator cares —
+                    # so a healthy print's normal tar churn doesn't spam the log.
+                    if _val != prev and _val not in (255, -1) and self.state.state == "PAUSE":
+                        logger.info(
+                            "[%s] AMS %s changed to %s while paused (expected/previous slot signal, #2587)",
+                            self.serial_number,
+                            _tk,
+                            _val,
+                        )
+
             # Parse tray_now from AMS dict - this is the currently loaded tray global ID
             # Note: tray_tar is also available but on H2D it's just slot number (0-3), not global ID
             if "tray_now" in ams_data:

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

@@ -997,6 +997,64 @@ def resolve_plate_id(state) -> int | None:
     return parse_plate_id(state.gcode_file)
 
 
+def resolve_expected_tray(
+    raw_slot: int | None,
+    ams_layout: list[tuple[int, bool]],
+    mapping_raw: object,
+) -> int | None:
+    """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
+
+    The firmware reports the target/previous slot as a bare number whose meaning
+    depends on the AMS layout (see ``PrinterState.tray_tar``). This mirrors the
+    ``tray_now`` handling so the resolved ID lines up with what the AMS graphic
+    already highlights via ``ams_id*4 + slot``.
+
+    ``ams_layout`` is a list of ``(ams_id, is_ams_ht)`` for the connected units.
+
+    - ``255``/``-1`` (none/idle) -> ``None``
+    - ``254`` (external spool) -> ``254``
+    - ``128``-``135`` (AMS-HT) -> already global, returned as-is
+    - ``0``-``3`` local slot:
+        * exactly one regular AMS -> ``ams_id*4 + slot``
+        * several regular AMS -> resolved via the snow-encoded ``mapping`` field
+          (each entry = ``ams_hw_id*256 + slot``; ``65535`` = unmapped), or
+          ``None`` when it stays ambiguous (honest "can't determine")
+        * no regular AMS -> ``None``
+    - ``4``-``15`` -> already a global regular-AMS ID, returned as-is
+
+    Returns ``None`` for anything it can't place, so the caller surfaces a
+    "check the printer" message instead of pointing at the wrong slot.
+    """
+    if raw_slot is None or raw_slot in (255, -1):
+        return None
+    if raw_slot == 254:
+        return 254
+    if 128 <= raw_slot <= 135:
+        return raw_slot
+    if 0 <= raw_slot <= 3:
+        regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
+        if len(regular) == 1:
+            return regular[0] * 4 + raw_slot
+        if len(regular) > 1:
+            if not isinstance(mapping_raw, list):
+                return None
+            candidates: set[int] = set()
+            for value in mapping_raw:
+                if not isinstance(value, int) or value >= 65535:
+                    continue
+                ams_hw_id = value >> 8
+                slot = value & 0xFF
+                if 0 <= ams_hw_id <= 3 and (slot & 0x03) == raw_slot:
+                    candidates.add(ams_hw_id * 4 + raw_slot)
+                elif 128 <= ams_hw_id <= 135 and raw_slot == 0:
+                    candidates.add(ams_hw_id)
+            return candidates.pop() if len(candidates) == 1 else None
+        return None
+    if 4 <= raw_slot <= 15:
+        return raw_slot
+    return None
+
+
 def printer_state_to_dict(
     state: PrinterState,
     printer_id: int | None = None,
@@ -1239,6 +1297,28 @@ def printer_state_to_dict(
         "ams_status_main": state.ams_status_main,
         "ams_status_sub": state.ams_status_sub,
         "tray_now": state.tray_now,
+        # Runout / filament-replacement guidance (#2587). Only meaningful while
+        # PAUSED — resolve the firmware's target/previous slot to a global tray ID
+        # so the AMS graphic can highlight the slot the print now expects and name
+        # the one that ran out. None when idle, not paused, or unresolvable.
+        "expected_tray": (
+            resolve_expected_tray(
+                state.tray_tar,
+                [(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
+        "previous_tray": (
+            resolve_expected_tray(
+                state.tray_pre,
+                [(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength

+ 73 - 0
backend/tests/unit/test_ams_tray_tar_parse_2587.py

@@ -0,0 +1,73 @@
+"""_handle_ams_data must capture tray_tar / tray_pre for the runout UI (#2587).
+
+The firmware reports the slot a paused print now expects (``tray_tar``) and the
+slot loaded before (``tray_pre``) alongside ``tray_now``. Bambuddy historically
+parsed only ``tray_now`` and dropped the other two, so "which slot does the
+print now expect" never reached the API. These tests lock in that the raw values
+are stored on PrinterState (globalisation happens later, at the API boundary).
+"""
+
+from unittest.mock import MagicMock, patch
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _client() -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="SERIAL", access_code="code", model="P1S")
+
+
+def _ams_frame(**extra):
+    frame = {
+        "ams": [
+            {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
+        ],
+    }
+    frame.update(extra)
+    return frame
+
+
+class TestTrayTarPreCapture:
+    def test_reporter_pause_values_are_stored(self):
+        client = _client()
+        client.state.state = "PAUSE"
+        # @Jostxxl's capture: ran out in slot 2 (tray_pre=1), expects slot 3 (tray_tar=2).
+        client._handle_ams_data(_ams_frame(tray_now=255, tray_tar=2, tray_pre=1))
+        assert client.state.tray_tar == 2
+        assert client.state.tray_pre == 1
+
+    def test_string_values_are_coerced(self):
+        client = _client()
+        client._handle_ams_data(_ams_frame(tray_tar="2", tray_pre="1"))
+        assert client.state.tray_tar == 2
+        assert client.state.tray_pre == 1
+
+    def test_defaults_untouched_when_absent(self):
+        client = _client()
+        # A frame without tray_tar/tray_pre leaves the 255 sentinel in place.
+        client._handle_ams_data(_ams_frame(tray_now=3))
+        assert client.state.tray_tar == 255
+        assert client.state.tray_pre == 255
+
+    def test_unparseable_value_falls_back_to_sentinel(self):
+        client = _client()
+        client._handle_ams_data(_ams_frame(tray_tar="not-a-number"))
+        assert client.state.tray_tar == 255
+
+    def test_change_while_paused_is_logged(self):
+        client = _client()
+        client.state.state = "PAUSE"
+        client.state.tray_tar = 255
+        with patch("backend.app.services.bambu_mqtt.logger") as log:
+            client._handle_ams_data(_ams_frame(tray_tar=2, tray_pre=1))
+        logged = " ".join(str(c) for c in log.info.call_args_list)
+        assert "tray_tar" in logged and "#2587" in logged
+
+    def test_no_log_when_not_paused(self):
+        client = _client()
+        client.state.state = "RUNNING"
+        client.state.tray_tar = 255
+        with patch("backend.app.services.bambu_mqtt.logger") as log:
+            client._handle_ams_data(_ams_frame(tray_tar=2))
+        # A healthy print's tar churn must not spam the log.
+        for call in log.info.call_args_list:
+            assert "#2587" not in " ".join(str(a) for a in call.args)

+ 119 - 0
backend/tests/unit/test_resolve_expected_tray_2587.py

@@ -0,0 +1,119 @@
+"""Unit tests for runout expected-slot resolution (#2587).
+
+When a print pauses on an AMS filament runout, the firmware reports the slot it
+now expects (``tray_tar``) and the slot that ran out (``tray_pre``) as bare
+numbers whose meaning depends on the AMS layout. ``resolve_expected_tray``
+globalises them to the same numbering the AMS graphic already highlights, so the
+UI can point the operator at the right physical slot — critical with AMS
+Filament Backup, which advances to the next compatible slot rather than
+re-accepting the depleted one (reporter @Jostxxl saw the print wait on Slot 3
+after Slot 2 ran out).
+"""
+
+from backend.app.services.printer_manager import resolve_expected_tray
+
+
+def _single_regular():
+    return [(0, False)]
+
+
+def _dual_regular():
+    return [(0, False), (1, False)]
+
+
+class TestSingleRegularAms:
+    """One 4-slot AMS: the local slot IS the global tray ID (matches tray_now)."""
+
+    def test_reporter_scenario_tray_tar_2_is_slot_3(self):
+        # @Jostxxl: tray_tar=2 (zero-based) -> physical Slot 3.
+        assert resolve_expected_tray(2, _single_regular(), None) == 2
+
+    def test_reporter_scenario_tray_pre_1_is_slot_2(self):
+        # @Jostxxl: tray_pre=1 (zero-based) -> physical Slot 2 (the one that ran out).
+        assert resolve_expected_tray(1, _single_regular(), None) == 1
+
+    def test_all_four_slots_pass_through(self):
+        for slot in range(4):
+            assert resolve_expected_tray(slot, _single_regular(), None) == slot
+
+    def test_single_ams_with_nonzero_id(self):
+        # A lone AMS reporting id=1 globalises to 4+slot, not the bare slot.
+        assert resolve_expected_tray(2, [(1, False)], None) == 6
+
+
+class TestSentinels:
+    """255 = none/idle, 254 = external spool, -1 = never-set."""
+
+    def test_none_input(self):
+        assert resolve_expected_tray(None, _single_regular(), None) is None
+
+    def test_255_is_none(self):
+        assert resolve_expected_tray(255, _single_regular(), None) is None
+
+    def test_minus_one_is_none(self):
+        assert resolve_expected_tray(-1, _single_regular(), None) is None
+
+    def test_254_external_passes_through(self):
+        assert resolve_expected_tray(254, _single_regular(), None) == 254
+
+
+class TestAmsHt:
+    """AMS-HT reports a global ID (128-135) directly — return it unchanged."""
+
+    def test_ht_global_id_passthrough(self):
+        assert resolve_expected_tray(129, [(129, True)], None) == 129
+
+    def test_ht_alongside_regular(self):
+        layout = [(0, False), (128, True)]
+        assert resolve_expected_tray(128, layout, None) == 128
+        # A 0-3 target still resolves against the single regular unit.
+        assert resolve_expected_tray(3, layout, None) == 3
+
+
+class TestMultiRegularAms:
+    """Several 4-slot AMS: local slot is ambiguous; resolve via the mapping field.
+
+    The snow-encoded mapping is ``ams_hw_id*256 + slot`` per entry.
+    """
+
+    def test_resolves_unambiguous_slot_via_mapping(self):
+        # Mapping says slot 2 lives on AMS 1 -> global 1*4+2 = 6.
+        mapping = [1 * 256 + 2]
+        assert resolve_expected_tray(2, _dual_regular(), mapping) == 6
+
+    def test_resolves_slot_on_ams0(self):
+        mapping = [0 * 256 + 3]
+        assert resolve_expected_tray(3, _dual_regular(), mapping) == 3
+
+    def test_ambiguous_when_two_units_match_returns_none(self):
+        # Both AMS 0 and AMS 1 have slot 1 mapped -> can't disambiguate.
+        mapping = [0 * 256 + 1, 1 * 256 + 1]
+        assert resolve_expected_tray(1, _dual_regular(), mapping) is None
+
+    def test_no_mapping_returns_none(self):
+        # Honest "can't determine" rather than guessing AMS 0.
+        assert resolve_expected_tray(2, _dual_regular(), None) is None
+
+    def test_unmapped_sentinel_ignored(self):
+        # 65535 = unmapped; only the real entry counts.
+        mapping = [65535, 1 * 256 + 0]
+        assert resolve_expected_tray(0, _dual_regular(), mapping) == 4
+
+    def test_ht_in_multi_layout_via_mapping(self):
+        # A slot-0 target that maps to an AMS-HT hw id resolves to that global ID.
+        layout = [(0, False), (1, False), (128, True)]
+        mapping = [128 * 256 + 0]
+        assert resolve_expected_tray(0, layout, mapping) == 128
+
+
+class TestGlobalAndOutOfRange:
+    def test_already_global_regular_id_passthrough(self):
+        # 4-15 is already a global regular-AMS ID.
+        assert resolve_expected_tray(6, _dual_regular(), None) == 6
+
+    def test_out_of_range_returns_none(self):
+        assert resolve_expected_tray(200, _single_regular(), None) is None
+
+    def test_zero_slot_no_regular_ams_returns_none(self):
+        # Only an AMS-HT present but a 0-3 target — can't place it.
+        assert resolve_expected_tray(1, [(128, True)], None) is None

+ 5 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -141,6 +141,7 @@ function isAlwaysAllowedIdentical(value) {
 // German loanwords / cognates from English are extensive. Most short technical
 // UI labels are identical in DE. List below curates the legitimate ones.
 const DE_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the DE term too
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
   'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
@@ -220,6 +221,7 @@ const FR_COGNATES = [
 
 // Italian cognates.
 const IT_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the IT term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
@@ -267,6 +269,7 @@ const JA_COGNATES = [
 
 // Portuguese (BR) cognates.
 const PT_BR_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the PT-BR term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (PT-BR)
@@ -341,6 +344,7 @@ const KO_COGNATES = [
 
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
 const ES_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the ES term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (ES)
@@ -363,6 +367,7 @@ const ES_COGNATES = [
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim
 // from English (loanwords + acronyms + format strings). Curated, not a shortcut.
 const TR_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the TR term too
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name

+ 67 - 0
frontend/src/__tests__/components/HMSErrorModal.test.tsx

@@ -25,6 +25,13 @@ const unknownError: HMSError = {
   severity: 1,
 };
 
+// Error code 0700_8011 = AMS filament runout (#2587).
+const runoutError: HMSError = {
+  attr: 0x0700,
+  code: '0x8011',
+  severity: 2,
+};
+
 describe('HMSErrorModal', () => {
   const defaultProps = {
     printerName: 'Test Printer',
@@ -122,6 +129,66 @@ describe('HMSErrorModal', () => {
     });
   });
 
+  describe('runout guidance (#2587)', () => {
+    it('shows the generic runout text when no guidance is provided', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[runoutError]} />);
+      expect(
+        screen.getByText('AMS filament ran out. Please insert a new filament into the same AMS slot.')
+      ).toBeInTheDocument();
+    });
+
+    it('names both the expected and ran-out slot when both are resolved', () => {
+      render(
+        <HMSErrorModal
+          {...defaultProps}
+          errors={[runoutError]}
+          runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
+        />
+      );
+      const p = screen.getByText(/waiting for compatible filament/i);
+      expect(p.textContent).toContain('AMS-A · Slot 3');
+      expect(p.textContent).toContain('AMS-A · Slot 2');
+      // The misleading "same slot" text must be gone.
+      expect(screen.queryByText(/into the same AMS slot/i)).not.toBeInTheDocument();
+    });
+
+    it('names only the expected slot when the ran-out slot is unknown', () => {
+      render(
+        <HMSErrorModal
+          {...defaultProps}
+          errors={[runoutError]}
+          runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: null }}
+        />
+      );
+      const p = screen.getByText(/waiting for compatible filament/i);
+      expect(p.textContent).toContain('AMS-A · Slot 3');
+    });
+
+    it('shows an honest fallback when the slot cannot be resolved', () => {
+      render(
+        <HMSErrorModal
+          {...defaultProps}
+          errors={[runoutError]}
+          runoutGuidance={{ expectedSlotLabel: null, ranOutSlotLabel: null }}
+        />
+      );
+      expect(screen.getByText(/could not determine which slot/i)).toBeInTheDocument();
+    });
+
+    it('does not apply runout guidance to non-runout errors', () => {
+      render(
+        <HMSErrorModal
+          {...defaultProps}
+          errors={[knownError]}
+          runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
+        />
+      );
+      // 0300_400C keeps its own description; no slot injection.
+      expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
+      expect(screen.queryByText(/waiting for compatible filament/i)).not.toBeInTheDocument();
+    });
+  });
+
   describe('interactions', () => {
     it('calls onClose when X button is clicked', async () => {
       const user = userEvent.setup();

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

@@ -522,6 +522,14 @@ export interface PrinterStatus {
   fila_switch: FilaSwitchState | null;
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   tray_now: number;
+  // Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
+  // Global tray IDs (ams_id*4+slot, 128-135 = AMS-HT, 254 = external), matching
+  // the same numbering as tray_now so the AMS graphic can highlight them.
+  //   expected_tray = the slot the firmware now expects filament in (null = idle,
+  //                   not paused, or unresolvable → "check the printer").
+  //   previous_tray = the slot that ran out (null = unknown).
+  expected_tray: number | null;
+  previous_tray: number | null;
   // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
   ams_status_main: number;
   // AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge

+ 36 - 2
frontend/src/components/HMSErrorModal.tsx

@@ -14,8 +14,25 @@ interface HMSErrorModalProps {
   onClose: () => void;
   printerId: number;
   hasPermission: (permission: Permission) => boolean;
+  // Runout guidance for a PAUSED print (#2587). When set, AMS-runout errors are
+  // re-described to name the physical slot the firmware now expects, instead of
+  // the generic "insert into the same slot" text (wrong under AMS Filament Backup).
+  // Slot labels are pre-formatted (e.g. "AMS-A · Slot 3"); null when the slot
+  // could not be resolved → an honest "check the printer" message is shown.
+  runoutGuidance?: {
+    expectedSlotLabel: string | null;
+    ranOutSlotLabel: string | null;
+  } | null;
 }
 
+// AMS per-slot filament-runout short codes (module 0x07). These pause the print
+// waiting for a specific slot — the ones #2587 re-describes. Printer-side /
+// external runout (0300_8004) has no AMS slot and is deliberately excluded.
+const AMS_RUNOUT_SHORT_CODES = new Set([
+  '0700_8011', '0701_8011', '0702_8011', '0703_8011', '0704_8011',
+  '0705_8011', '0706_8011', '0707_8011', '07FF_8011',
+]);
+
 // Comprehensive error code database (short format: XXXX_YYYY)
 // Auto-generated from ha-bambulab - 853 codes
 const ERROR_DESCRIPTIONS: Record<string, string> = {
@@ -917,7 +934,7 @@ function getHMSHomeUrl(): string {
   return `https://wiki.bambulab.com/en/hms/home`;
 }
 
-export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission }: HMSErrorModalProps) {
+export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission, runoutGuidance }: HMSErrorModalProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -1003,7 +1020,24 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                 const { label, color, bgColor, Icon } = getSeverityInfo(error.severity);
                 const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
                 const shortCode = getShortCode(error.attr, codeNum);
-                const description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
+                // Runout guidance (#2587): for an AMS per-slot runout on a paused
+                // print, name the slot the firmware now expects rather than the
+                // misleading generic "insert into the same slot" text.
+                let description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
+                if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
+                  if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
+                    description = t('hmsErrors.runoutExpectedSlot', {
+                      expected: runoutGuidance.expectedSlotLabel,
+                      ranOut: runoutGuidance.ranOutSlotLabel,
+                    });
+                  } else if (runoutGuidance.expectedSlotLabel) {
+                    description = t('hmsErrors.runoutExpectedSlotOnly', {
+                      expected: runoutGuidance.expectedSlotLabel,
+                    });
+                  } else {
+                    description = t('hmsErrors.runoutSlotUnknown');
+                  }
+                }
                 const hmsHomeUrl = getHMSHomeUrl();
                 const displayCode = shortCode.replace('_', '-');
 

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

@@ -604,6 +604,12 @@ export default {
       title: 'Dieser Slot ist Filament {{n}} im aktiven Druck',
       ariaLabel: 'Aktiver Druck-Slot {{n}}',
     },
+    expectedSlot: {
+      title: 'Der Drucker wartet auf Filament in diesem Slot',
+      ariaLabel: 'Erwarteter Filament-Slot {{n}}',
+      label: '{{ams}} · Slot {{slot}}',
+      external: 'Externe Spule',
+    },
     // Filaments section
     filaments: 'Filamente',
     // Camera
@@ -2805,6 +2811,9 @@ export default {
     clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
     actionSuccess: 'Aktion an Drucker gesendet',
     actionFailed: 'Aktion konnte nicht gesendet werden',
+    runoutExpectedSlot: 'Das Filament in {{ranOut}} ist aufgebraucht. Der Drucker wartet jetzt auf kompatibles Filament in {{expected}}. Legen Sie eine Spule in {{expected}} ein und wählen Sie dann Wiederholen.',
+    runoutExpectedSlotOnly: 'Der Drucker wartet auf kompatibles Filament in {{expected}}. Legen Sie dort eine Spule ein und wählen Sie dann Wiederholen.',
+    runoutSlotUnknown: 'Das Filament ist aufgebraucht und der Druck ist pausiert. Bambuddy konnte nicht ermitteln, welchen Slot der Drucker jetzt erwartet — prüfen Sie am Druckerdisplay, welcher Slot angefordert wird.',
     actions: {
       RESUME_PRINTING: 'Druck fortsetzen',
       RESUME_PRINTING_DEFECTS: 'Fortsetzen (Mängel akzeptabel)',

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

@@ -608,6 +608,12 @@ export default {
       title: 'This slot is filament {{n}} in the active print',
       ariaLabel: 'Active print slot {{n}}',
     },
+    expectedSlot: {
+      title: 'The printer is waiting for filament in this slot',
+      ariaLabel: 'Expected filament slot {{n}}',
+      label: '{{ams}} · Slot {{slot}}',
+      external: 'External spool',
+    },
     // Filaments section
     filaments: 'Filaments',
     // Camera
@@ -2834,6 +2840,9 @@ export default {
     clearFailed: 'Failed to clear HMS errors',
     actionSuccess: 'Action sent to printer',
     actionFailed: 'Failed to send action',
+    runoutExpectedSlot: 'Filament ran out in {{ranOut}}. The printer is now waiting for compatible filament in {{expected}}. Insert a spool into {{expected}}, then select Retry.',
+    runoutExpectedSlotOnly: 'The printer is waiting for compatible filament in {{expected}}. Insert a spool there, then select Retry.',
+    runoutSlotUnknown: 'Filament ran out and the print is paused. Bambuddy could not determine which slot the printer now expects — check the printer screen for the requested slot.',
     actions: {
       RESUME_PRINTING: "Resume Printing",
       RESUME_PRINTING_DEFECTS: "Resume (defects acceptable)",

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

@@ -604,6 +604,12 @@ export default {
       title: 'Este slot es el filamento {{n}} en la impresión activa',
       ariaLabel: 'Slot de impresión activa {{n}}',
     },
+    expectedSlot: {
+      title: 'La impresora está esperando filamento en este slot',
+      ariaLabel: 'Slot de filamento esperado {{n}}',
+      label: '{{ams}} · Slot {{slot}}',
+      external: 'Bobina externa',
+    },
     // Filaments section
     filaments: 'Filamentos',
     // Camera
@@ -2808,6 +2814,9 @@ export default {
     clearFailed: 'Error al borrar los errores HMS',
     actionSuccess: 'Acción enviada a la impresora',
     actionFailed: 'No se pudo enviar la acción',
+    runoutExpectedSlot: 'El filamento se agotó en {{ranOut}}. La impresora ahora espera filamento compatible en {{expected}}. Inserta una bobina en {{expected}} y luego selecciona Reintentar.',
+    runoutExpectedSlotOnly: 'La impresora está esperando filamento compatible en {{expected}}. Inserta una bobina ahí y luego selecciona Reintentar.',
+    runoutSlotUnknown: 'El filamento se agotó y la impresión está en pausa. Bambuddy no pudo determinar qué slot espera ahora la impresora — revisa la pantalla de la impresora para ver el slot solicitado.',
     actions: {
       RESUME_PRINTING: 'Reanudar impresión',
       RESUME_PRINTING_DEFECTS: 'Reanudar (defectos aceptables)',

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

@@ -604,6 +604,12 @@ export default {
       title: 'Cet emplacement est le filament {{n}} dans l\'impression active',
       ariaLabel: 'Emplacement d\'impression active {{n}}',
     },
+    expectedSlot: {
+      title: 'L\'imprimante attend du filament dans cet emplacement',
+      ariaLabel: 'Emplacement de filament attendu {{n}}',
+      label: '{{ams}} · Emplacement {{slot}}',
+      external: 'Bobine externe',
+    },
     // Filaments section
     filaments: 'Filaments',
     // Camera
@@ -2794,6 +2800,9 @@ export default {
     clearFailed: 'Échec de l\'effacement des erreurs HMS',
     actionSuccess: 'Action envoyée à l\'imprimante',
     actionFailed: 'Échec de l\'envoi de l\'action',
+    runoutExpectedSlot: 'Le filament est épuisé dans {{ranOut}}. L\'imprimante attend maintenant du filament compatible dans {{expected}}. Insérez une bobine dans {{expected}}, puis sélectionnez Réessayer.',
+    runoutExpectedSlotOnly: 'L\'imprimante attend du filament compatible dans {{expected}}. Insérez-y une bobine, puis sélectionnez Réessayer.',
+    runoutSlotUnknown: 'Le filament est épuisé et l\'impression est en pause. Bambuddy n\'a pas pu déterminer quel emplacement l\'imprimante attend désormais — vérifiez l\'écran de l\'imprimante pour l\'emplacement demandé.',
     actions: {
       RESUME_PRINTING: 'Reprendre l\'impression',
       RESUME_PRINTING_DEFECTS: 'Reprendre (défauts acceptables)',

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

@@ -604,6 +604,12 @@ export default {
       title: 'Questo slot è il filamento {{n}} nella stampa attiva',
       ariaLabel: 'Slot stampa attiva {{n}}',
     },
+    expectedSlot: {
+      title: 'La stampante è in attesa di filamento in questo slot',
+      ariaLabel: 'Slot filamento previsto {{n}}',
+      label: '{{ams}} · Slot {{slot}}',
+      external: 'Bobina esterna',
+    },
     // Filaments section
     filaments: 'Filamenti',
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: 'Impossibile cancellare gli errori HMS',
     actionSuccess: 'Azione inviata alla stampante',
     actionFailed: 'Impossibile inviare l\'azione',
+    runoutExpectedSlot: 'Il filamento in {{ranOut}} è esaurito. La stampante ora attende filamento compatibile in {{expected}}. Inserisci una bobina in {{expected}}, quindi seleziona Riprova.',
+    runoutExpectedSlotOnly: 'La stampante attende filamento compatibile in {{expected}}. Inserisci lì una bobina, quindi seleziona Riprova.',
+    runoutSlotUnknown: 'Il filamento è esaurito e la stampa è in pausa. Bambuddy non è riuscito a determinare quale slot la stampante attende ora — controlla lo schermo della stampante per lo slot richiesto.',
     actions: {
       RESUME_PRINTING: 'Riprendi stampa',
       RESUME_PRINTING_DEFECTS: 'Riprendi (difetti accettabili)',

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

@@ -603,6 +603,12 @@ export default {
       title: 'このスロットはアクティブな印刷のフィラメント {{n}} です',
       ariaLabel: 'アクティブ印刷スロット {{n}}',
     },
+    expectedSlot: {
+      title: 'プリンターはこのスロットのフィラメントを待っています',
+      ariaLabel: '要求スロット {{n}}',
+      label: '{{ams}} · スロット {{slot}}',
+      external: '外部スプール',
+    },
     // Filaments section
     filaments: 'フィラメント',
     // Camera
@@ -2805,6 +2811,9 @@ export default {
     clearFailed: 'HMSエラーのクリアに失敗しました',
     actionSuccess: 'アクションをプリンターに送信しました',
     actionFailed: 'アクションの送信に失敗しました',
+    runoutExpectedSlot: '{{ranOut}} のフィラメントが切れました。プリンターは現在 {{expected}} に対応するフィラメントを待っています。{{expected}} にスプールをセットして、「再試行」を選択してください。',
+    runoutExpectedSlotOnly: 'プリンターは {{expected}} に対応するフィラメントを待っています。そこにスプールをセットして、「再試行」を選択してください。',
+    runoutSlotUnknown: 'フィラメントが切れて印刷が一時停止しています。プリンターが現在要求しているスロットを Bambuddy が特定できませんでした。プリンターの画面で要求されているスロットを確認してください。',
     actions: {
       RESUME_PRINTING: '印刷を再開',
       RESUME_PRINTING_DEFECTS: '再開(不具合を許容)',

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

@@ -567,6 +567,12 @@ export default {
       title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다',
       ariaLabel: '활성 인쇄 슬롯 {{n}}'
     },
+    expectedSlot: {
+      title: '프린터가 이 슬롯의 필라멘트를 기다리고 있습니다',
+      ariaLabel: '요청된 필라멘트 슬롯 {{n}}',
+      label: '{{ams}} · 슬롯 {{slot}}',
+      external: '외부 스풀',
+    },
     filaments: '필라멘트',
     openCameraOverlay: '카메라 오버레이 열기',
     openCameraWindow: '새 창에서 카메라 열기',
@@ -2655,6 +2661,9 @@ export default {
     clearFailed: 'HMS 오류 지우기 실패',
     actionSuccess: '프린터에 작업을 전송함',
     actionFailed: '작업 전송 실패',
+    runoutExpectedSlot: '{{ranOut}}의 필라멘트가 소진되었습니다. 프린터가 이제 {{expected}}에 호환 필라멘트를 기다리고 있습니다. {{expected}}에 스풀을 넣은 다음 다시 시도를 선택하세요.',
+    runoutExpectedSlotOnly: '프린터가 {{expected}}에 호환 필라멘트를 기다리고 있습니다. 거기에 스풀을 넣은 다음 다시 시도를 선택하세요.',
+    runoutSlotUnknown: '필라멘트가 소진되어 인쇄가 일시정지되었습니다. Bambuddy가 프린터가 현재 요청하는 슬롯을 확인할 수 없습니다 — 프린터 화면에서 요청된 슬롯을 확인하세요.',
     actions: {
       RESUME_PRINTING: '인쇄 재개',
       RESUME_PRINTING_DEFECTS: '재개 (결함 허용)',

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

@@ -604,6 +604,12 @@ export default {
       title: 'Este slot é o filamento {{n}} na impressão ativa',
       ariaLabel: 'Slot de impressão ativa {{n}}',
     },
+    expectedSlot: {
+      title: 'A impressora está aguardando filamento neste slot',
+      ariaLabel: 'Slot de filamento esperado {{n}}',
+      label: '{{ams}} · Slot {{slot}}',
+      external: 'Bobina externa',
+    },
     // Filaments section
     filaments: 'Filamentos',
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: 'Falha ao limpar erros HMS',
     actionSuccess: 'Ação enviada à impressora',
     actionFailed: 'Falha ao enviar ação',
+    runoutExpectedSlot: 'O filamento acabou em {{ranOut}}. A impressora agora aguarda filamento compatível em {{expected}}. Insira uma bobina em {{expected}} e selecione Tentar novamente.',
+    runoutExpectedSlotOnly: 'A impressora está aguardando filamento compatível em {{expected}}. Insira uma bobina ali e selecione Tentar novamente.',
+    runoutSlotUnknown: 'O filamento acabou e a impressão está pausada. O Bambuddy não conseguiu determinar qual slot a impressora agora espera — verifique a tela da impressora para o slot solicitado.',
     actions: {
       RESUME_PRINTING: 'Retomar impressão',
       RESUME_PRINTING_DEFECTS: 'Retomar (defeitos aceitáveis)',

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

@@ -604,6 +604,12 @@ export default {
       title: 'Bu slot, aktif baskıdaki {{n}} numaralı filament',
       ariaLabel: 'Aktif baskı slotu {{n}}',
     },
+    expectedSlot: {
+      title: 'Yazıcı bu slotta filament bekliyor',
+      ariaLabel: 'Beklenen filament slotu {{n}}',
+      label: '{{ams}} · Slot {{slot}}',
+      external: 'Harici makara',
+    },
     // Filamentler bölümü
     filaments: 'Filamentler',
     // Kamera
@@ -2809,6 +2815,9 @@ export default {
     clearFailed: 'HMS hataları temizlenemedi',
     actionSuccess: 'Eylem yazıcıya gönderildi',
     actionFailed: 'Eylem gönderilemedi',
+    runoutExpectedSlot: '{{ranOut}} slotundaki filament bitti. Yazıcı şimdi {{expected}} slotunda uyumlu filament bekliyor. {{expected}} slotuna bir makara takın ve ardından Yeniden Dene\'yi seçin.',
+    runoutExpectedSlotOnly: 'Yazıcı {{expected}} slotunda uyumlu filament bekliyor. Oraya bir makara takın ve ardından Yeniden Dene\'yi seçin.',
+    runoutSlotUnknown: 'Filament bitti ve baskı duraklatıldı. Bambuddy yazıcının şu anda hangi slotu beklediğini belirleyemedi — istenen slot için yazıcının ekranını kontrol edin.',
     actions: {
       RESUME_PRINTING: 'Baskıyı sürdür',
       RESUME_PRINTING_DEFECTS: 'Sürdür (kusurlar kabul edilebilir)',

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

@@ -604,6 +604,12 @@ export default {
       title: '此料槽在当前打印中是耗材 {{n}}',
       ariaLabel: '当前打印料槽 {{n}}',
     },
+    expectedSlot: {
+      title: '打印机正在等待此料槽装入耗材',
+      ariaLabel: '需要装料的料槽 {{n}}',
+      label: '{{ams}} · 料槽 {{slot}}',
+      external: '外部料卷',
+    },
     // Filaments section
     filaments: '耗材',
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: '清除 HMS 错误失败',
     actionSuccess: '已向打印机发送操作',
     actionFailed: '操作发送失败',
+    runoutExpectedSlot: '{{ranOut}} 的耗材已用尽。打印机现在正在等待 {{expected}} 装入兼容耗材。请将料卷装入 {{expected}},然后选择"重试"。',
+    runoutExpectedSlotOnly: '打印机正在等待 {{expected}} 装入兼容耗材。请在该料槽装入料卷,然后选择"重试"。',
+    runoutSlotUnknown: '耗材已用尽,打印已暂停。Bambuddy 无法确定打印机现在需要哪个料槽——请在打印机屏幕上查看所请求的料槽。',
     actions: {
       RESUME_PRINTING: '恢复打印',
       RESUME_PRINTING_DEFECTS: '恢复 (缺陷可接受)',

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

@@ -604,6 +604,12 @@ export default {
       title: '此料槽在目前列印中是耗材 {{n}}',
       ariaLabel: '目前列印料槽 {{n}}',
     },
+    expectedSlot: {
+      title: '印表機正在等待此料槽裝入耗材',
+      ariaLabel: '需要裝料的料槽 {{n}}',
+      label: '{{ams}} · 料槽 {{slot}}',
+      external: '外部料卷',
+    },
     // Filaments section
     filaments: '耗材',
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: '清除 HMS 錯誤失敗',
     actionSuccess: '已向印表機傳送動作',
     actionFailed: '動作傳送失敗',
+    runoutExpectedSlot: '{{ranOut}} 的耗材已用盡。印表機現在正在等待 {{expected}} 裝入相容耗材。請將料卷裝入 {{expected}},然後選擇「重試」。',
+    runoutExpectedSlotOnly: '印表機正在等待 {{expected}} 裝入相容耗材。請在該料槽裝入料卷,然後選擇「重試」。',
+    runoutSlotUnknown: '耗材已用盡,列印已暫停。Bambuddy 無法確定印表機現在需要哪個料槽——請在印表機螢幕上查看所請求的料槽。',
     actions: {
       RESUME_PRINTING: '恢復列印',
       RESUME_PRINTING_DEFECTS: '恢復 (瑕疵可接受)',

+ 72 - 2
frontend/src/pages/PrintersPage.tsx

@@ -2105,6 +2105,34 @@ function PrinterCard({
     ? currentTrayNow
     : cachedTrayNow.current;
 
+  // Runout / filament-replacement guidance (#2587). The backend fills these only
+  // while the print is PAUSED (global tray IDs, or null when idle/unresolvable):
+  //   expectedTray = the slot the firmware now expects filament in
+  //   previousTray = the slot that ran out
+  // With AMS Filament Backup the firmware advances to the next compatible slot,
+  // so these are often different — the graphic highlights the expected one.
+  const expectedTray = status?.expected_tray ?? null;
+  const previousTray = status?.previous_tray ?? null;
+  // Pre-format the runout slot labels (honoring user AMS friendly names) for the
+  // HMS modal (#2587). null when the slot can't be placed → honest fallback copy.
+  const formatRunoutSlotLabel = (globalId: number | null): string | null => {
+    if (globalId === null) return null;
+    if (globalId === 254) return t('printers.expectedSlot.external');
+    if (globalId >= 128 && globalId <= 135) {
+      return amsLabels?.[globalId] || getAmsLabel(globalId, 1);
+    }
+    const amsId = Math.floor(globalId / 4);
+    const slot = globalId % 4;
+    const amsName = amsLabels?.[amsId] || getAmsLabel(amsId, 4);
+    return t('printers.expectedSlot.label', { ams: amsName, slot: slot + 1 });
+  };
+  const runoutGuidance = status?.state === 'PAUSE'
+    ? {
+        expectedSlotLabel: formatRunoutSlotLabel(expectedTray),
+        ranOutSlotLabel: formatRunoutSlotLabel(previousTray),
+      }
+    : null;
+
   // Fetch smart plug for this printer
   const { data: smartPlug } = useQuery({
     queryKey: ['smartPlugByPrinter', printer.id],
@@ -4633,6 +4661,10 @@ function PrinterCard({
                                 // Global tray ID = ams.id * 4 + slot index (for standard AMS)
                                 const globalTrayId = ams.id * 4 + slotIdx;
                                 const isActive = effectiveTrayNow === globalTrayId;
+                                // Runout guidance (#2587): the slot the paused print now
+                                // expects filament in, and the slot that ran out.
+                                const isExpectedSlot = expectedTray !== null && expectedTray === globalTrayId;
+                                const isRanOutSlot = previousTray !== null && previousTray === globalTrayId;
                                 // Get cloud preset info if available
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 // Get saved slot preset mapping (for user-configured slots)
@@ -4704,8 +4736,25 @@ function PrinterCard({
                                 // Slot visual content (goes inside hover card)
                                 const slotVisual = (
                                   <div
-                                    className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                                    className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${
+                                      isExpectedSlot
+                                        ? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-bambu-dark animate-pulse'
+                                        : isRanOutSlot
+                                          ? 'ring-2 ring-red-500/60 ring-offset-1 ring-offset-bambu-dark'
+                                          : isActive
+                                            ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark'
+                                            : ''
+                                    }`}
                                   >
+                                    {isExpectedSlot && (
+                                      <span
+                                        aria-label={t('printers.expectedSlot.ariaLabel', { n: slotIdx + 1 })}
+                                        title={t('printers.expectedSlot.title')}
+                                        className="absolute top-0.5 left-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-amber-400 rounded pointer-events-none leading-none"
+                                      >
+                                        ↓
+                                      </span>
+                                    )}
                                     {activePrintSlotLabel && (
                                       <span
                                         aria-label={t('printers.activeJobSlot.ariaLabel', { n: activePrintSlotIdx + 1 })}
@@ -4914,6 +4963,9 @@ function PrinterCard({
                       // Check if this is the currently loaded tray
                       const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                       const isActive = effectiveTrayNow === globalTrayId;
+                      // Runout guidance (#2587): expected / ran-out slot on this HT unit.
+                      const isExpectedSlot = expectedTray !== null && expectedTray === globalTrayId;
+                      const isRanOutSlot = previousTray !== null && previousTray === globalTrayId;
                       // Get cloud preset info if available
                       const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                       // Get saved slot preset mapping (for user-configured slots)
@@ -4976,8 +5028,25 @@ function PrinterCard({
                         // Slot visual content (goes inside hover card)
                         const slotVisual = (
                           <div
-                            className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                            className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${
+                              isExpectedSlot
+                                ? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-bambu-dark animate-pulse'
+                                : isRanOutSlot
+                                  ? 'ring-2 ring-red-500/60 ring-offset-1 ring-offset-bambu-dark'
+                                  : isActive
+                                    ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark'
+                                    : ''
+                            }`}
                           >
+                            {isExpectedSlot && (
+                              <span
+                                aria-label={t('printers.expectedSlot.ariaLabel', { n: 1 })}
+                                title={t('printers.expectedSlot.title')}
+                                className="absolute top-0.5 left-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-amber-400 rounded pointer-events-none leading-none"
+                              >
+                                ↓
+                              </span>
+                            )}
                             {htActivePrintSlotLabel && (
                               <span
                                 aria-label={t('printers.activeJobSlot.ariaLabel', { n: htActivePrintSlotIdx + 1 })}
@@ -6188,6 +6257,7 @@ function PrinterCard({
           onClose={() => setShowHMSModal(false)}
           printerId={printer.id}
           hasPermission={hasPermission}
+          runoutGuidance={runoutGuidance}
         />
       )}
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-BikDm6kr.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Byt13KYk.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-CZwzTgpo.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-z4krdH8i.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BikDm6kr.css">
+    <script type="module" crossorigin src="/assets/index-Byt13KYk.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов