فهرست منبع

fix(hms): wrong-plate Ignore actually ignores + buttons read as buttons + ack-detection survives transient re-pause (#1869)

The HMS error modal had three compounding bugs that surfaced when a
user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
per-fault actions.

(1) IGNORE_RESUME did not ignore. Bambuddy redirected the action on
state=PAUSE to a plain `resume` command, citing a #1830 verdict that
BambuStudio's "err-bearing shape" was firmware-silently-rejected.
BambuStudio source disagrees: DeviceErrorDialog.cpp:600 dispatches
IGNORE_RESUME via command_hms_ignore, whose wire shape is
{command:"ignore", err:"<decimal>", param:"reserve", job_id:...}.
That's a distinct command from `resume` — the firmware suppresses
the next re-check AND auto-resumes in one operation. Plain resume
means "re-check normally", which is exactly why the wrong-plate
detection re-fired 1-2 s after the user clicked Ignore. The #1830
"err-bearing shape rejected" test almost certainly sent the err as
a hex shortcode; BambuStudio passes std::to_string(int m_error_code)
i.e. the DECIMAL form, which is what the firmware matches against.

(2) Action buttons read as inert badges. The button className used
`hover:${buttonHoverColor}` — a template-literal interpolation
Tailwind's JIT scanner can't see as a literal string, so the
per-severity hover utility never reached the compiled CSS. Same
bg/text color as the severity badge above and no border made it
read as another label. No disabled state and no spinner during the
2.5 s ack wait left clicks sitting silently inert.

(3) Ack-detection 502'd on legitimate ack. The route compared
(gcode_state, hms_errors-len) before vs after publish; wrong-plate
re-pause round-tripped both fields to their pre-publish values
inside the 2.5 s window → false 502 even though the firmware fully
ack'd. PROBLEM_SOLVED_RESUME working but IGNORE_RESUME 502'ing on
the same fault was the same race resolving differently.

Fixes:

bambu_mqtt.py — new hms_ignore_command() publishes the BambuStudio
shape; existing hms_ignore(persistent) renamed to hms_idle_ignore
(unchanged shape, used by NO_REMINDER_NEXT_TIME per
DeviceErrorDialog.cpp:588). Dispatch routes IGNORE_RESUME,
IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME to
hms_ignore_command (BambuStudio routes all three to the same
command_hms_ignore — the "don't remind" half is the firmware's
job). NO_REMINDER_NEXT_TIME stays on hms_idle_ignore type=0. Hex →
decimal err conversion at the helper layer with a defensive
fallback. job_id=None → empty string (matches BambuStudio's
std::string default).

HMSErrorModal.tsx — getSeverityInfo loses the dead buttonHoverColor
field. Action button uses static
`bg-white/10 hover:bg-white/20 active:bg-white/30 text-white
border border-white/20`, wires
`disabled={!hasPermission||mutation.isPending}`, and renders
`<Loader2/>` only on the button whose (action,print_error) matches
mutation.variables.

printers.py — ack-detection probes `client._last_message_time`
(bumped on every MQTT push regardless of payload) rather than
diffing state fields. The pushall that follows every command
guarantees a fresh push lands inside the 2.5 s window on any
healthy printer; only firmware-silent-drop leaves the timestamp
untouched, which is the 502 path #1830 wanted.
maziggy 2 ماه پیش
والد
کامیت
a45d32efd0

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 15 - 12
backend/app/api/routes/printers.py

@@ -3824,14 +3824,17 @@ async def execute_hms_action(
     # command. publish() success is NOT the same as printer-ack: Bambu's
     # firmware silently rejects malformed HMS commands at QoS 1 (the broker
     # ACKs the publish, but the printer drops it). Verified end-to-end against
-    # a live H2D — see #1830 §(3). We sample (gcode_state, hms_errors length)
-    # because every accepted HMS action mutates at least one of them.
+    # a live H2D — see #1830 §(3).
     #
-    # PrinterState.state carries the MQTT `gcode_state` value verbatim (see
-    # bambu_mqtt.py line 2144); the raw `print_error` int isn't preserved on
-    # state, only the derived HMSError entries are.
-    pre_gcode = client.state.state
-    pre_hms_count = len(client.state.hms_errors)
+    # We probe `_last_message_time` (bumped on every MQTT push) rather than a
+    # (gcode_state, hms_errors-length) diff. The old diff missed the
+    # wrong-plate IGNORE_RESUME case where the printer briefly resumes and
+    # re-pauses with the same fault inside the 2.5s window: both fields
+    # round-trip to their pre-publish values → false 502 even though the
+    # firmware fully ack'd the resume. Every accepted command triggers a
+    # pushall response within ~100-500ms, so a fresh inbound message after
+    # the publish is the robust ack signal.
+    pre_last_message = client._last_message_time
 
     success = client.execute_hms_action(body.print_error, body.action, body.job_id)
     if not success:
@@ -3845,12 +3848,12 @@ async def execute_hms_action(
     # coroutine is awaiting.
     await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
 
-    acked = client.state.state != pre_gcode or len(client.state.hms_errors) != pre_hms_count
+    acked = client._last_message_time > pre_last_message
     if not acked:
-        # Publish succeeded but the printer's state didn't move. Almost always
-        # firmware-side silent rejection (err mismatch, command/state mismatch).
-        # 502 makes it visible at the UI instead of the 200-but-broken loop
-        # #1830 reported.
+        # Publish succeeded but the printer sent nothing back. Almost always
+        # firmware-side silent rejection (err mismatch, command/state mismatch)
+        # or a dropped MQTT route. 502 makes it visible at the UI instead of
+        # the 200-but-broken loop #1830 reported.
         raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
 
     return {"success": True, "message": "HMS action executed"}

+ 78 - 36
backend/app/services/bambu_mqtt.py

@@ -5439,14 +5439,18 @@ class BambuMQTTClient:
             print_error: Canonical hex identifier for the fault — 8 chars for the
                 32-bit `print_error` path, 16 chars for the 64-bit `hms[]` path
                 (HMSError.full_code). Carried through unchanged from the route.
-                Only the `idle_ignore` branch puts it on the wire; resume / stop
-                use BambuStudio's plain shape (verified against a live H2D, the
-                `err`-bearing shape is silently rejected by the firmware).
+                Converted to its DECIMAL string form for the `ignore` /
+                `idle_ignore` commands' `err` field, which is what the firmware
+                actually compares against the active fault. The pre-#1869
+                hex-string `err` was silently rejected because the firmware was
+                being asked to match `"05008051"` against int 0x05008051
+                (= 83918929 decimal) — see BambuStudio's
+                DeviceManager.cpp:1450-1462 (`command_hms_ignore`) which passes
+                `std::to_string(int m_error_code)`.
             action: One of HMSAction's string values.
             job_id: The `subtask_id` snapshotted onto the HMSError at parse-time.
-                Preserved for symmetry with the catalog but no longer sent —
-                BambuStudio's actual resume/stop commands are plain and the
-                firmware doesn't echo `job_id` back on the response either.
+                Required by BambuStudio's `command_hms_ignore` / `command_hms_stop`
+                shapes; empty string is the no-job-id sentinel.
 
         Returns False when the MQTT client is offline or when `action` is unknown
         so the route surfaces it as a 4xx rather than a silent no-op.
@@ -5464,12 +5468,24 @@ class BambuMQTTClient:
                 self.topic_publish, json.dumps({"pushing": {"command": "pushall", "sequence_id": "0"}}), qos=1
             )
 
+        # BambuStudio's `err` field is the DECIMAL string of the error code's int
+        # value (DeviceErrorDialog.cpp passes `std::to_string(m_error_code)` to
+        # every command_hms_* call). Our route hands us the hex string —
+        # convert. Falls back to the raw input if it's not parseable so the
+        # firmware can reject it and the route can surface 502 instead of us
+        # raising ValueError mid-dispatch.
+        try:
+            err_decimal = str(int(print_error, 16))
+        except ValueError:
+            err_decimal = print_error
+
         def hms_resume():
-            # BambuStudio's actual shape — plain resume, no err / no job_id.
-            # The `err`-bearing shape (`err`, `param: "reserve"`, `job_id`) is
-            # silently rejected by Bambu firmware on print_error- and hms[]-sourced
-            # faults alike; verified by injecting candidate shapes against a live
-            # H2D paused on a wrong-plate HMS. See #1830 §(2).
+            # Plain resume — verified against the user's H2D/H2S to leave PAUSE
+            # cleanly when "Problem Solved and Resume" is clicked. BambuStudio
+            # sends `{command: "resume", err: "<decimal>", param: "reserve",
+            # job_id: ...}` from `command_hms_resume`; we kept the simpler
+            # shape historically because it works, and changing it without a
+            # field test risks regressing a path that the user has confirmed.
             publish(
                 {
                     "print": {
@@ -5481,8 +5497,8 @@ class BambuMQTTClient:
             )
 
         def hms_stop():
-            # Same as hms_resume — BambuStudio's actual shape is plain. The
-            # `err`-bearing variant is silently rejected; verified on the H2D.
+            # Same as hms_resume — plain shape, confirmed working by the user
+            # for "Stop Printing".
             publish(
                 {
                     "print": {
@@ -5493,29 +5509,46 @@ class BambuMQTTClient:
                 }
             )
 
-        def hms_ignore(persistent: bool = False):
-            # `idle_ignore` is BambuStudio's "dismiss this warning" command for
-            # non-pause warnings. type=0 dismisses once, type=1 hides the same
-            # warning permanently.
-            #
-            # For HMS-paused state, `idle_ignore` is silently rejected by the
-            # firmware regardless of `err` (verified on a live H2D — see
-            # #1830 §(2)). The user-facing intent of "Ignore and resume" on a
-            # paused print is to continue, so we dispatch a plain resume
-            # instead. The `persistent` flag is informational in that branch —
-            # firmware can't honour "don't remind" through a resume — but the
-            # button still does what the user expects.
+        def hms_ignore_command():
+            # BambuStudio's `command_hms_ignore` (DeviceManager.cpp:1450) —
+            # what the "Ignore this and Resume" button actually publishes.
+            # Distinct from `idle_ignore`: this command has the firmware
+            # suppress the next re-check of the named fault AND resume the
+            # paused print in a single operation. The previous Bambuddy code
+            # redirected IGNORE_RESUME to a plain `resume`, which is why the
+            # wrong-plate HMS came back 1-2 s later: `resume` means "I fixed
+            # the problem, re-check normally" so the firmware re-detected the
+            # wrong plate and re-paused with the same code (#1869).
             #
-            # NB: PrinterState's `state` field carries the MQTT `gcode_state`
-            # value verbatim — line 2144 stores `data["gcode_state"]` onto it.
-            if self.state.state == "PAUSE":
-                hms_resume()
-                return
+            # BambuStudio also routes IGNORE_NO_REMINDER_NEXT_TIME (a.k.a.
+            # DONT_REMIND_NEXT_TIME) to this same command — the persistent
+            # variant of "don't remind next time" lives on `idle_ignore`'s
+            # type=1, not as a separate ignore shape.
+            publish(
+                {
+                    "print": {
+                        "command": "ignore",
+                        "err": err_decimal,
+                        "param": "reserve",
+                        "job_id": job_id or "",
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def hms_idle_ignore(persistent: bool = False):
+            # `idle_ignore` is BambuStudio's "dismiss this warning without
+            # resuming" command for non-pause warnings — what
+            # `command_hms_idle_ignore` (DeviceManager.cpp:1424) sends.
+            # type=0 dismisses once, type=1 suppresses the same warning
+            # permanently. Used by NO_REMINDER_NEXT_TIME, which BambuStudio
+            # explicitly dispatches via `command_hms_idle_ignore(..., 0)` —
+            # NOT via the resume-bearing `ignore` command.
             publish(
                 {
                     "print": {
                         "command": "idle_ignore",
-                        "err": print_error,
+                        "err": err_decimal,
                         "type": 1 if persistent else 0,
                         "sequence_id": "0",
                     }
@@ -5577,11 +5610,20 @@ class BambuMQTTClient:
             case HMSAction.STOP_PRINTING:
                 hms_stop()
 
-            case HMSAction.IGNORE_RESUME | HMSAction.NO_REMINDER_NEXT_TIME:
-                hms_ignore(persistent=False)
-
-            case HMSAction.IGNORE_NO_REMINDER_NEXT_TIME | HMSAction.DONT_REMIND_NEXT_TIME:
-                hms_ignore(persistent=True)
+            case HMSAction.IGNORE_RESUME | HMSAction.IGNORE_NO_REMINDER_NEXT_TIME | HMSAction.DONT_REMIND_NEXT_TIME:
+                # All three buttons map to BambuStudio's `command_hms_ignore`
+                # (DeviceErrorDialog.cpp:596-602). The "no reminder next time"
+                # half of IGNORE_NO_REMINDER_NEXT_TIME is the firmware's
+                # responsibility — the wire shape is identical.
+                hms_ignore_command()
+
+            case HMSAction.NO_REMINDER_NEXT_TIME:
+                # BambuStudio's NO_REMINDER_NEXT_TIME branch dispatches
+                # `command_hms_idle_ignore` with type=0
+                # (DeviceErrorDialog.cpp:588-590). Distinct from the
+                # IGNORE_* buttons above: idle_ignore does NOT resume, only
+                # dismisses the dialog.
+                hms_idle_ignore(persistent=False)
 
             case HMSAction.FILAMENT_EXTRUDED | HMSAction.DBL_CHECK_DONE:
                 ams_control("done")

+ 61 - 14
backend/tests/integration/test_printers_api.py

@@ -1770,24 +1770,26 @@ class TestExecuteHMSActionAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_execute_hms_action_success(self, async_client: AsyncClient, printer_factory):
-        """200 happy path — dispatcher returns True AND printer state moves
-        within the ack-wait window. The state delta is the firmware's only
-        proof that the command landed (publish success is necessary but not
-        sufficient; see #1830 §(3))."""
+        """200 happy path — dispatcher returns True AND the printer pushes at
+        least one MQTT message into the ack-wait window. A fresh inbound
+        message is the firmware's proof that the command landed (publish
+        success is necessary but not sufficient; see #1830 §(3))."""
         printer = await printer_factory(name="Test Printer")
 
         mock_client = MagicMock()
-        # Pre-action state — paused with a fault.
+        # Pre-action state — paused with a fault, last message arrived at t=0.
         mock_client.state.state = "PAUSE"
         mock_client.state.print_error = 0x05008051
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
 
         def _act(*_a, **_kw):
-            # Simulate the printer accepting the command and clearing the fault
-            # by the time the ack-wait expires.
-            mock_client.state.state = "FAILED"
-            mock_client.state.print_error = 0
-            mock_client.state.hms_errors = []
+            # Simulate the printer pushing a status update within the ack-wait
+            # window. The pushall that follows every command is what produces
+            # this — the actual state fields don't have to move (#1869: a
+            # wrong-plate IGNORE_RESUME re-pauses with the same fault but the
+            # printer DID push back).
+            mock_client._last_message_time = 100.5
             return True
 
         mock_client.execute_hms_action.side_effect = _act
@@ -1830,8 +1832,8 @@ class TestExecuteHMSActionAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_execute_hms_action_no_printer_ack_returns_502(self, async_client: AsyncClient, printer_factory):
-        """502 when publish succeeded but printer state didn't move within the
-        ack-wait window. This is the silent-rejection failure mode #1830
+        """502 when publish succeeded but no MQTT message arrives back within
+        the ack-wait window. This is the silent-rejection failure mode #1830
         identifies: the broker ACKs the publish at QoS 1 but the firmware
         drops the command (err mismatch, wrong shape, state mismatch).
         Surfacing this as 502 instead of 200 stops the UI from claiming
@@ -1842,8 +1844,9 @@ class TestExecuteHMSActionAPI:
         mock_client.state.state = "PAUSE"
         mock_client.state.print_error = 0x05008051
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
         mock_client.execute_hms_action.return_value = True  # publish "succeeded"
-        # Crucially: state does NOT change → ack-wait detects no movement.
+        # Crucially: _last_message_time does NOT advance → no inbound push.
 
         with (
             patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
@@ -1858,6 +1861,49 @@ class TestExecuteHMSActionAPI:
             assert response.status_code == 502
             assert "acknowledge" in response.json()["detail"].lower()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_ignore_resume_repauses_within_window_still_acks(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """200 when the printer ack'd the command but immediately re-paused
+        with the same fault — e.g. wrong-plate IGNORE_RESUME (#1869). The
+        previous (gcode_state, hms_errors-len) diff produced a false 502
+        because both fields round-tripped to their pre-publish values inside
+        the ack window. Probing `_last_message_time` survives the round-trip
+        because the printer's status push lands regardless of the eventual
+        state."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.state = "PAUSE"
+        mock_client.state.print_error = 0x05008051
+        mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
+
+        def _act(*_a, **_kw):
+            # Printer ack'd, briefly resumed, re-detected the wrong plate, and
+            # re-paused with the same fault. Net diff on state fields is zero,
+            # but a fresh status push DID arrive.
+            mock_client._last_message_time = 100.4
+            mock_client.state.state = "PAUSE"  # round-tripped
+            mock_client.state.hms_errors = [object()]  # same length
+            return True
+
+        mock_client.execute_hms_action.side_effect = _act
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
+        ):
+            mock_pm.get_client.return_value = mock_client
+
+            body = {"print_error": "05008051", "action": "IGNORE_RESUME", "job_id": None}
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_execute_hms_action_accepts_16_char_full_code(self, async_client: AsyncClient, printer_factory):
@@ -1870,9 +1916,10 @@ class TestExecuteHMSActionAPI:
         mock_client.state.state = "RUNNING"
         mock_client.state.print_error = 0
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
 
         def _act(*_a, **_kw):
-            mock_client.state.hms_errors = []
+            mock_client._last_message_time = 100.4
             return True
 
         mock_client.execute_hms_action.side_effect = _act

+ 64 - 33
backend/tests/unit/services/test_hms_actions.py

@@ -131,61 +131,92 @@ class TestExecuteHmsActionDispatch:
         assert "err" not in cmds[0]["print"]
         assert "job_id" not in cmds[0]["print"]
 
-    def test_ignore_resume_dispatches_resume_when_print_paused(self, client):
-        # Verified on H2D: idle_ignore is silently rejected while gcode_state
-        # is PAUSE. The user's intent on a paused HMS modal is to continue,
-        # so IGNORE_RESUME dispatches a plain resume instead. See #1830 §(2).
+    def test_ignore_resume_sends_bambustudio_ignore_command_paused(self, client):
+        # IGNORE_RESUME dispatches BambuStudio's `command_hms_ignore`
+        # (DeviceManager.cpp:1450) — `command: "ignore"`, not `resume` and
+        # not `idle_ignore`. The firmware handles both "skip this check on the
+        # next attempt" and "resume the paused print" in one operation.
+        # The previous Bambuddy code redirected to plain resume, which caused
+        # wrong-plate to re-pause 1-2 s after the user clicked Ignore (#1869).
+        # `err` is the DECIMAL int representation of the hex error code.
         client.state.state = "PAUSE"
-        client.execute_hms_action("03008070", HMSAction.IGNORE_RESUME)
+        client.execute_hms_action("05008051", HMSAction.IGNORE_RESUME, job_id="task-7")
         cmds = self._published_commands(client)
         assert cmds[0] == {
             "print": {
-                "command": "resume",
-                "param": "",
+                "command": "ignore",
+                "err": str(0x05008051),  # "83918929"
+                "param": "reserve",
+                "job_id": "task-7",
                 "sequence_id": "0",
             }
         }
 
-    def test_ignore_resume_uses_idle_ignore_when_not_paused(self, client):
-        # For non-pause warnings (e.g. AMS-side prompts during printing),
-        # idle_ignore IS the correct command and the firmware honours it.
+    def test_ignore_resume_state_independent(self, client):
+        # BambuStudio's DeviceErrorDialog dispatches IGNORE_RESUME via
+        # `command_hms_ignore` unconditionally — there's no PAUSE-vs-RUNNING
+        # branch. Bambuddy's previous code special-cased PAUSE to a plain
+        # resume; the BambuStudio shape works in both states.
         client.state.state = "RUNNING"
-        client.execute_hms_action("03008070", HMSAction.IGNORE_RESUME)
+        client.execute_hms_action("05008051", HMSAction.IGNORE_RESUME)
+        cmds = self._published_commands(client)
+        assert cmds[0]["print"]["command"] == "ignore"
+        assert cmds[0]["print"]["err"] == str(0x05008051)
+
+    def test_ignore_no_reminder_uses_ignore_command_not_idle_ignore(self, client):
+        # BambuStudio routes IGNORE_NO_REMINDER_NEXT_TIME and
+        # DONT_REMIND_NEXT_TIME to the same `command_hms_ignore` as
+        # IGNORE_RESUME (DeviceErrorDialog.cpp:596-602) — the "don't remind"
+        # half is the firmware's responsibility, the wire shape is identical.
+        client.state.state = "PAUSE"
+        client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME, job_id="task-1")
+        cmds = self._published_commands(client)
+        assert cmds[0] == {
+            "print": {
+                "command": "ignore",
+                "err": str(0x03008070),
+                "param": "reserve",
+                "job_id": "task-1",
+                "sequence_id": "0",
+            }
+        }
+
+    def test_no_reminder_next_time_uses_idle_ignore_type_zero(self, client):
+        # NO_REMINDER_NEXT_TIME (distinct from IGNORE_NO_REMINDER_NEXT_TIME)
+        # is BambuStudio's `command_hms_idle_ignore` with type=0
+        # (DeviceErrorDialog.cpp:588-590). Dismisses the dialog without
+        # resuming. The `err` is the same decimal-int format as the ignore
+        # command — same `m_error_code` is passed in BambuStudio.
+        client.state.state = "RUNNING"
+        client.execute_hms_action("03008070", HMSAction.NO_REMINDER_NEXT_TIME)
         cmds = self._published_commands(client)
         assert cmds[0] == {
             "print": {
                 "command": "idle_ignore",
-                "err": "03008070",
+                "err": str(0x03008070),
                 "type": 0,
                 "sequence_id": "0",
             }
         }
 
-    def test_dont_remind_dispatches_resume_when_paused(self, client):
-        # The persistent variant still degrades to resume on a paused print —
-        # the "don't remind" flag can't ride along on a resume, but the user
-        # clicked an action whose top-level intent is to continue, so we
-        # honour that. The behavioural contract is documented in hms_ignore.
+    def test_ignore_accepts_16_char_full_code_as_decimal(self, client):
+        # hms[]-array faults carry a 16-char full identifier. Bambu's firmware
+        # matches `err` as a numeric string, so the 16-char hex parses to a
+        # 64-bit int and serializes back as its decimal form.
         client.state.state = "PAUSE"
-        client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME)
-        cmds = self._published_commands(client)
-        assert cmds[0]["print"]["command"] == "resume"
-
-    def test_dont_remind_uses_idle_ignore_type_one_when_not_paused(self, client):
-        client.state.state = "RUNNING"
-        client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME)
+        client.execute_hms_action("0C00030000020010", HMSAction.IGNORE_RESUME)
         cmds = self._published_commands(client)
-        assert cmds[0]["print"]["command"] == "idle_ignore"
-        assert cmds[0]["print"]["type"] == 1
+        assert cmds[0]["print"]["command"] == "ignore"
+        assert cmds[0]["print"]["err"] == str(0x0C00030000020010)
 
-    def test_idle_ignore_accepts_16_char_full_code(self, client):
-        # hms[]-array faults carry a 16-char full identifier. The firmware
-        # matches against the full 64-bit code; the truncated 8-char form
-        # (used pre-#1830) was silently rejected on H2C.
-        client.state.state = "RUNNING"
-        client.execute_hms_action("0C00030000020010", HMSAction.IGNORE_RESUME)
+    def test_ignore_with_no_job_id_sends_empty_string(self, client):
+        # BambuStudio's `command_hms_ignore` always passes `m_obj->job_id_`
+        # (a `std::string` — empty when there's no active subtask). Match the
+        # shape: empty string, not None / missing key.
+        client.state.state = "PAUSE"
+        client.execute_hms_action("05008051", HMSAction.IGNORE_RESUME, job_id=None)
         cmds = self._published_commands(client)
-        assert cmds[0]["print"]["err"] == "0C00030000020010"
+        assert cmds[0]["print"]["job_id"] == ""
 
     def test_filament_extruded_sends_ams_done(self, client):
         client.execute_hms_action("07008029", HMSAction.FILAMENT_EXTRUDED)

+ 42 - 26
frontend/src/components/HMSErrorModal.tsx

@@ -874,17 +874,17 @@ const ERROR_DESCRIPTIONS: Record<string, string> = {
   '18FF_C00A': 'Please observe the nozzle of the right extruder. If the filament has been extruded, select \'Continue\'; if not, please push the filament forward slightly and then select \'Retry\'.',
 };
 
-function getSeverityInfo(severity: number): { label: string; color: string; bgColor: string; buttonHoverColor: string; Icon: typeof AlertTriangle } {
+function getSeverityInfo(severity: number): { label: string; color: string; bgColor: string; Icon: typeof AlertTriangle } {
   switch (severity) {
     case 1:
-      return { label: 'Fatal', color: 'text-red-500', bgColor: 'bg-red-500/20', buttonHoverColor: 'bg-red-500/10', Icon: AlertTriangle };
+      return { label: 'Fatal', color: 'text-red-500', bgColor: 'bg-red-500/20', Icon: AlertTriangle };
     case 2:
-      return { label: 'Serious', color: 'text-red-400', bgColor: 'bg-red-500/15', buttonHoverColor: 'bg-red-500/10', Icon: AlertTriangle };
+      return { label: 'Serious', color: 'text-red-400', bgColor: 'bg-red-500/15', Icon: AlertTriangle };
     case 3:
-      return { label: 'Warning', color: 'text-orange-400', bgColor: 'bg-orange-500/20', buttonHoverColor: 'bg-orange-500/10', Icon: AlertCircle };
+      return { label: 'Warning', color: 'text-orange-400', bgColor: 'bg-orange-500/20', Icon: AlertCircle };
     case 4:
     default:
-      return { label: 'Info', color: 'text-blue-400', bgColor: 'bg-blue-500/20', buttonHoverColor: 'bg-blue-500/10', Icon: Info };
+      return { label: 'Info', color: 'text-blue-400', bgColor: 'bg-blue-500/20', Icon: Info };
   }
 }
 
@@ -1000,7 +1000,7 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
           ) : (
             <div className="space-y-3">
               {knownErrors.map((error, index) => {
-                const { label, color, bgColor, buttonHoverColor, Icon } = getSeverityInfo(error.severity);
+                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');
@@ -1024,26 +1024,42 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                         <p className="text-sm text-bambu-gray mb-2">{description}</p>
                         {error.actions && error.actions.length > 0 && (
                           <div className="flex flex-wrap gap-2 my-2">
-                            {error.actions.map((action) => (
-                              <button
-                                key={action}
-                                onClick={() => {
-                                  // full_code is the firmware-matching key (16
-                                  // chars for hms[]-array faults, 8 chars for
-                                  // print_error). Fall back to the 8-char
-                                  // shortCode for older backends that haven't
-                                  // populated it. See #1830.
-                                  activateActionMutation.mutate({
-                                    action,
-                                    print_error: error.full_code || shortCode.replace("_", ""),
-                                    job_id: error.job_id ?? null,
-                                  });
-                                }}
-                                className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg ${bgColor} ${color} hover:${buttonHoverColor} transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0`}
-                              >
-                                {t(`hmsErrors.actions.${action}`, action)}
-                              </button>
-                            ))}
+                            {error.actions.map((action) => {
+                              const pendingVars = activateActionMutation.variables;
+                              const isThisPending =
+                                activateActionMutation.isPending
+                                && pendingVars?.action === action
+                                && pendingVars?.print_error === (error.full_code || shortCode.replace('_', ''));
+                              return (
+                                <button
+                                  key={action}
+                                  onClick={() => {
+                                    // full_code is the firmware-matching key (16
+                                    // chars for hms[]-array faults, 8 chars for
+                                    // print_error). Fall back to the 8-char
+                                    // shortCode for older backends that haven't
+                                    // populated it. See #1830.
+                                    activateActionMutation.mutate({
+                                      action,
+                                      print_error: error.full_code || shortCode.replace('_', ''),
+                                      job_id: error.job_id ?? null,
+                                    });
+                                  }}
+                                  // Static hover/active classes — Tailwind's JIT
+                                  // can't resolve `hover:${var}` template
+                                  // literals, so the previous severity-tinted
+                                  // hover never reached the compiled CSS and
+                                  // the action buttons read as inert badges.
+                                  // White-on-tint reads as a clear affordance
+                                  // against any severity-coloured container.
+                                  disabled={!hasPermission('printers:control') || activateActionMutation.isPending}
+                                  className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-white/10 hover:bg-white/20 active:bg-white/30 text-white border border-white/20 hover:border-white/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
+                                >
+                                  {isThisPending && <Loader2 className="w-4 h-4 animate-spin" />}
+                                  {t(`hmsErrors.actions.${action}`, action)}
+                                </button>
+                              );
+                            })}
                           </div>
                         )}
                         <a

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-BCPW417s.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CFA6-beJ.js


+ 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-DlLdjNuD.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BYzbe9TT.css">
+    <script type="module" crossorigin src="/assets/index-CFA6-beJ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BCPW417s.css">
   </head>
   <body>
     <div id="root"></div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است