Procházet zdrojové kódy

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 před 1 měsícem
rodič
revize
e77e10896f

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 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,
     drying_screen_only,
     get_derived_status_name,
     get_derived_status_name,
     printer_manager,
     printer_manager,
+    resolve_expected_tray,
     resolve_plate_id,
     resolve_plate_id,
     supports_chamber_heater,
     supports_chamber_heater,
     supports_chamber_temp,
     supports_chamber_temp,
@@ -766,6 +767,26 @@ async def get_printer_status(
         ams_mapping=ams_mapping,
         ams_mapping=ams_mapping,
         ams_extruder_map=ams_extruder_map,
         ams_extruder_map=ams_extruder_map,
         tray_now=tray_now,
         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_main=state.ams_status_main,
         ams_status_sub=state.ams_status_sub,
         ams_status_sub=state.ams_status_sub,
         mc_print_sub_stage=state.mc_print_sub_stage,
         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
     fila_switch: FilaSwitchResponse | None = None
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
     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
     # AMS status for filament change tracking
     # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
     # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
     ams_status_main: int = 0
     ams_status_main: int = 0

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

@@ -321,6 +321,15 @@ class PrinterState:
     active_extruder: int = 0
     active_extruder: int = 0
     # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     tray_now: int = 255
     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 valid tray_now (0-253) — survives unload (255) for usage tracking after print completes
     last_loaded_tray: int = -1
     last_loaded_tray: int = -1
     # Pending load target - used to track what tray we're loading for H2D disambiguation
     # 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,
                     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
             # 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
             # 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:
             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)
     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(
 def printer_state_to_dict(
     state: PrinterState,
     state: PrinterState,
     printer_id: int | None = None,
     printer_id: int | None = None,
@@ -1239,6 +1297,28 @@ def printer_state_to_dict(
         "ams_status_main": state.ams_status_main,
         "ams_status_main": state.ams_status_main,
         "ams_status_sub": state.ams_status_sub,
         "ams_status_sub": state.ams_status_sub,
         "tray_now": state.tray_now,
         "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
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         "ams_extruder_map": ams_extruder_map,
         "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength
         # 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
 // German loanwords / cognates from English are extensive. Most short technical
 // UI labels are identical in DE. List below curates the legitimate ones.
 // UI labels are identical in DE. List below curates the legitimate ones.
 const DE_COGNATES = [
 const DE_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the DE term too
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
   'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
   'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
@@ -220,6 +221,7 @@ const FR_COGNATES = [
 
 
 // Italian cognates.
 // Italian cognates.
 const IT_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
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Email',  // common loanword in Italian, used verbatim in UI labels
@@ -267,6 +269,7 @@ const JA_COGNATES = [
 
 
 // Portuguese (BR) cognates.
 // Portuguese (BR) cognates.
 const PT_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
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (PT-BR)
   'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (PT-BR)
@@ -341,6 +344,7 @@ const KO_COGNATES = [
 
 
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
 const ES_COGNATES = [
 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
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (ES)
   'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (ES)
@@ -363,6 +367,7 @@ const ES_COGNATES = [
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim
 // from English (loanwords + acronyms + format strings). Curated, not a shortcut.
 // from English (loanwords + acronyms + format strings). Curated, not a shortcut.
 const TR_COGNATES = [
 const TR_COGNATES = [
+  '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the TR term too
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   '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,
   severity: 1,
 };
 };
 
 
+// Error code 0700_8011 = AMS filament runout (#2587).
+const runoutError: HMSError = {
+  attr: 0x0700,
+  code: '0x8011',
+  severity: 2,
+};
+
 describe('HMSErrorModal', () => {
 describe('HMSErrorModal', () => {
   const defaultProps = {
   const defaultProps = {
     printerName: 'Test Printer',
     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', () => {
   describe('interactions', () => {
     it('calls onClose when X button is clicked', async () => {
     it('calls onClose when X button is clicked', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();

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

@@ -522,6 +522,14 @@ export interface PrinterStatus {
   fila_switch: FilaSwitchState | null;
   fila_switch: FilaSwitchState | null;
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   tray_now: number;
   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 for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
   ams_status_main: number;
   ams_status_main: number;
   // AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
   // 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;
   onClose: () => void;
   printerId: number;
   printerId: number;
   hasPermission: (permission: Permission) => boolean;
   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)
 // Comprehensive error code database (short format: XXXX_YYYY)
 // Auto-generated from ha-bambulab - 853 codes
 // Auto-generated from ha-bambulab - 853 codes
 const ERROR_DESCRIPTIONS: Record<string, string> = {
 const ERROR_DESCRIPTIONS: Record<string, string> = {
@@ -917,7 +934,7 @@ function getHMSHomeUrl(): string {
   return `https://wiki.bambulab.com/en/hms/home`;
   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 { t } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -1003,7 +1020,24 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                 const { label, color, bgColor, Icon } = getSeverityInfo(error.severity);
                 const { label, color, bgColor, Icon } = getSeverityInfo(error.severity);
                 const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
                 const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
                 const shortCode = getShortCode(error.attr, codeNum);
                 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 hmsHomeUrl = getHMSHomeUrl();
                 const displayCode = shortCode.replace('_', '-');
                 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',
       title: 'Dieser Slot ist Filament {{n}} im aktiven Druck',
       ariaLabel: 'Aktiver Druck-Slot {{n}}',
       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 section
     filaments: 'Filamente',
     filaments: 'Filamente',
     // Camera
     // Camera
@@ -2805,6 +2811,9 @@ export default {
     clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
     clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
     actionSuccess: 'Aktion an Drucker gesendet',
     actionSuccess: 'Aktion an Drucker gesendet',
     actionFailed: 'Aktion konnte nicht gesendet werden',
     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: {
     actions: {
       RESUME_PRINTING: 'Druck fortsetzen',
       RESUME_PRINTING: 'Druck fortsetzen',
       RESUME_PRINTING_DEFECTS: 'Fortsetzen (Mängel akzeptabel)',
       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',
       title: 'This slot is filament {{n}} in the active print',
       ariaLabel: 'Active print slot {{n}}',
       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 section
     filaments: 'Filaments',
     filaments: 'Filaments',
     // Camera
     // Camera
@@ -2834,6 +2840,9 @@ export default {
     clearFailed: 'Failed to clear HMS errors',
     clearFailed: 'Failed to clear HMS errors',
     actionSuccess: 'Action sent to printer',
     actionSuccess: 'Action sent to printer',
     actionFailed: 'Failed to send action',
     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: {
     actions: {
       RESUME_PRINTING: "Resume Printing",
       RESUME_PRINTING: "Resume Printing",
       RESUME_PRINTING_DEFECTS: "Resume (defects acceptable)",
       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',
       title: 'Este slot es el filamento {{n}} en la impresión activa',
       ariaLabel: 'Slot de impresión activa {{n}}',
       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 section
     filaments: 'Filamentos',
     filaments: 'Filamentos',
     // Camera
     // Camera
@@ -2808,6 +2814,9 @@ export default {
     clearFailed: 'Error al borrar los errores HMS',
     clearFailed: 'Error al borrar los errores HMS',
     actionSuccess: 'Acción enviada a la impresora',
     actionSuccess: 'Acción enviada a la impresora',
     actionFailed: 'No se pudo enviar la acción',
     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: {
     actions: {
       RESUME_PRINTING: 'Reanudar impresión',
       RESUME_PRINTING: 'Reanudar impresión',
       RESUME_PRINTING_DEFECTS: 'Reanudar (defectos aceptables)',
       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',
       title: 'Cet emplacement est le filament {{n}} dans l\'impression active',
       ariaLabel: 'Emplacement d\'impression active {{n}}',
       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 section
     filaments: 'Filaments',
     filaments: 'Filaments',
     // Camera
     // Camera
@@ -2794,6 +2800,9 @@ export default {
     clearFailed: 'Échec de l\'effacement des erreurs HMS',
     clearFailed: 'Échec de l\'effacement des erreurs HMS',
     actionSuccess: 'Action envoyée à l\'imprimante',
     actionSuccess: 'Action envoyée à l\'imprimante',
     actionFailed: 'Échec de l\'envoi de l\'action',
     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: {
     actions: {
       RESUME_PRINTING: 'Reprendre l\'impression',
       RESUME_PRINTING: 'Reprendre l\'impression',
       RESUME_PRINTING_DEFECTS: 'Reprendre (défauts acceptables)',
       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',
       title: 'Questo slot è il filamento {{n}} nella stampa attiva',
       ariaLabel: 'Slot stampa attiva {{n}}',
       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 section
     filaments: 'Filamenti',
     filaments: 'Filamenti',
     // Camera
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: 'Impossibile cancellare gli errori HMS',
     clearFailed: 'Impossibile cancellare gli errori HMS',
     actionSuccess: 'Azione inviata alla stampante',
     actionSuccess: 'Azione inviata alla stampante',
     actionFailed: 'Impossibile inviare l\'azione',
     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: {
     actions: {
       RESUME_PRINTING: 'Riprendi stampa',
       RESUME_PRINTING: 'Riprendi stampa',
       RESUME_PRINTING_DEFECTS: 'Riprendi (difetti accettabili)',
       RESUME_PRINTING_DEFECTS: 'Riprendi (difetti accettabili)',

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

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

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

@@ -567,6 +567,12 @@ export default {
       title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다',
       title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다',
       ariaLabel: '활성 인쇄 슬롯 {{n}}'
       ariaLabel: '활성 인쇄 슬롯 {{n}}'
     },
     },
+    expectedSlot: {
+      title: '프린터가 이 슬롯의 필라멘트를 기다리고 있습니다',
+      ariaLabel: '요청된 필라멘트 슬롯 {{n}}',
+      label: '{{ams}} · 슬롯 {{slot}}',
+      external: '외부 스풀',
+    },
     filaments: '필라멘트',
     filaments: '필라멘트',
     openCameraOverlay: '카메라 오버레이 열기',
     openCameraOverlay: '카메라 오버레이 열기',
     openCameraWindow: '새 창에서 카메라 열기',
     openCameraWindow: '새 창에서 카메라 열기',
@@ -2655,6 +2661,9 @@ export default {
     clearFailed: 'HMS 오류 지우기 실패',
     clearFailed: 'HMS 오류 지우기 실패',
     actionSuccess: '프린터에 작업을 전송함',
     actionSuccess: '프린터에 작업을 전송함',
     actionFailed: '작업 전송 실패',
     actionFailed: '작업 전송 실패',
+    runoutExpectedSlot: '{{ranOut}}의 필라멘트가 소진되었습니다. 프린터가 이제 {{expected}}에 호환 필라멘트를 기다리고 있습니다. {{expected}}에 스풀을 넣은 다음 다시 시도를 선택하세요.',
+    runoutExpectedSlotOnly: '프린터가 {{expected}}에 호환 필라멘트를 기다리고 있습니다. 거기에 스풀을 넣은 다음 다시 시도를 선택하세요.',
+    runoutSlotUnknown: '필라멘트가 소진되어 인쇄가 일시정지되었습니다. Bambuddy가 프린터가 현재 요청하는 슬롯을 확인할 수 없습니다 — 프린터 화면에서 요청된 슬롯을 확인하세요.',
     actions: {
     actions: {
       RESUME_PRINTING: '인쇄 재개',
       RESUME_PRINTING: '인쇄 재개',
       RESUME_PRINTING_DEFECTS: '재개 (결함 허용)',
       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',
       title: 'Este slot é o filamento {{n}} na impressão ativa',
       ariaLabel: 'Slot de impressão ativa {{n}}',
       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 section
     filaments: 'Filamentos',
     filaments: 'Filamentos',
     // Camera
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: 'Falha ao limpar erros HMS',
     clearFailed: 'Falha ao limpar erros HMS',
     actionSuccess: 'Ação enviada à impressora',
     actionSuccess: 'Ação enviada à impressora',
     actionFailed: 'Falha ao enviar ação',
     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: {
     actions: {
       RESUME_PRINTING: 'Retomar impressão',
       RESUME_PRINTING: 'Retomar impressão',
       RESUME_PRINTING_DEFECTS: 'Retomar (defeitos aceitáveis)',
       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',
       title: 'Bu slot, aktif baskıdaki {{n}} numaralı filament',
       ariaLabel: 'Aktif baskı slotu {{n}}',
       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ü
     // Filamentler bölümü
     filaments: 'Filamentler',
     filaments: 'Filamentler',
     // Kamera
     // Kamera
@@ -2809,6 +2815,9 @@ export default {
     clearFailed: 'HMS hataları temizlenemedi',
     clearFailed: 'HMS hataları temizlenemedi',
     actionSuccess: 'Eylem yazıcıya gönderildi',
     actionSuccess: 'Eylem yazıcıya gönderildi',
     actionFailed: 'Eylem gönderilemedi',
     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: {
     actions: {
       RESUME_PRINTING: 'Baskıyı sürdür',
       RESUME_PRINTING: 'Baskıyı sürdür',
       RESUME_PRINTING_DEFECTS: 'Sürdür (kusurlar kabul edilebilir)',
       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}}',
       title: '此料槽在当前打印中是耗材 {{n}}',
       ariaLabel: '当前打印料槽 {{n}}',
       ariaLabel: '当前打印料槽 {{n}}',
     },
     },
+    expectedSlot: {
+      title: '打印机正在等待此料槽装入耗材',
+      ariaLabel: '需要装料的料槽 {{n}}',
+      label: '{{ams}} · 料槽 {{slot}}',
+      external: '外部料卷',
+    },
     // Filaments section
     // Filaments section
     filaments: '耗材',
     filaments: '耗材',
     // Camera
     // Camera
@@ -2793,6 +2799,9 @@ export default {
     clearFailed: '清除 HMS 错误失败',
     clearFailed: '清除 HMS 错误失败',
     actionSuccess: '已向打印机发送操作',
     actionSuccess: '已向打印机发送操作',
     actionFailed: '操作发送失败',
     actionFailed: '操作发送失败',
+    runoutExpectedSlot: '{{ranOut}} 的耗材已用尽。打印机现在正在等待 {{expected}} 装入兼容耗材。请将料卷装入 {{expected}},然后选择"重试"。',
+    runoutExpectedSlotOnly: '打印机正在等待 {{expected}} 装入兼容耗材。请在该料槽装入料卷,然后选择"重试"。',
+    runoutSlotUnknown: '耗材已用尽,打印已暂停。Bambuddy 无法确定打印机现在需要哪个料槽——请在打印机屏幕上查看所请求的料槽。',
     actions: {
     actions: {
       RESUME_PRINTING: '恢复打印',
       RESUME_PRINTING: '恢复打印',
       RESUME_PRINTING_DEFECTS: '恢复 (缺陷可接受)',
       RESUME_PRINTING_DEFECTS: '恢复 (缺陷可接受)',

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

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

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

@@ -2105,6 +2105,34 @@ function PrinterCard({
     ? currentTrayNow
     ? currentTrayNow
     : cachedTrayNow.current;
     : 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
   // Fetch smart plug for this printer
   const { data: smartPlug } = useQuery({
   const { data: smartPlug } = useQuery({
     queryKey: ['smartPlugByPrinter', printer.id],
     queryKey: ['smartPlugByPrinter', printer.id],
@@ -4633,6 +4661,10 @@ function PrinterCard({
                                 // Global tray ID = ams.id * 4 + slot index (for standard AMS)
                                 // Global tray ID = ams.id * 4 + slot index (for standard AMS)
                                 const globalTrayId = ams.id * 4 + slotIdx;
                                 const globalTrayId = ams.id * 4 + slotIdx;
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 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
                                 // Get cloud preset info if available
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 // Get saved slot preset mapping (for user-configured slots)
                                 // Get saved slot preset mapping (for user-configured slots)
@@ -4704,8 +4736,25 @@ function PrinterCard({
                                 // Slot visual content (goes inside hover card)
                                 // Slot visual content (goes inside hover card)
                                 const slotVisual = (
                                 const slotVisual = (
                                   <div
                                   <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 && (
                                     {activePrintSlotLabel && (
                                       <span
                                       <span
                                         aria-label={t('printers.activeJobSlot.ariaLabel', { n: activePrintSlotIdx + 1 })}
                                         aria-label={t('printers.activeJobSlot.ariaLabel', { n: activePrintSlotIdx + 1 })}
@@ -4914,6 +4963,9 @@ function PrinterCard({
                       // Check if this is the currently loaded tray
                       // Check if this is the currently loaded tray
                       const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                       const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                       const isActive = effectiveTrayNow === globalTrayId;
                       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
                       // Get cloud preset info if available
                       const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                       const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                       // Get saved slot preset mapping (for user-configured slots)
                       // Get saved slot preset mapping (for user-configured slots)
@@ -4976,8 +5028,25 @@ function PrinterCard({
                         // Slot visual content (goes inside hover card)
                         // Slot visual content (goes inside hover card)
                         const slotVisual = (
                         const slotVisual = (
                           <div
                           <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 && (
                             {htActivePrintSlotLabel && (
                               <span
                               <span
                                 aria-label={t('printers.activeJobSlot.ariaLabel', { n: htActivePrintSlotIdx + 1 })}
                                 aria-label={t('printers.activeJobSlot.ariaLabel', { n: htActivePrintSlotIdx + 1 })}
@@ -6188,6 +6257,7 @@ function PrinterCard({
           onClose={() => setShowHMSModal(false)}
           onClose={() => setShowHMSModal(false)}
           printerId={printer.id}
           printerId={printer.id}
           hasPermission={hasPermission}
           hasPermission={hasPermission}
+          runoutGuidance={runoutGuidance}
         />
         />
       )}
       )}
 
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/index-BikDm6kr.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-Byt13KYk.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
static/assets/index-CZwzTgpo.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů