Explorar o código

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 hai 2 meses
pai
achega
a45d32efd0

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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
     # command. publish() success is NOT the same as printer-ack: Bambu's
     # firmware silently rejects malformed HMS commands at QoS 1 (the broker
     # firmware silently rejects malformed HMS commands at QoS 1 (the broker
     # ACKs the publish, but the printer drops it). Verified end-to-end against
     # 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)
     success = client.execute_hms_action(body.print_error, body.action, body.job_id)
     if not success:
     if not success:
@@ -3845,12 +3848,12 @@ async def execute_hms_action(
     # coroutine is awaiting.
     # coroutine is awaiting.
     await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
     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:
     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")
         raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
 
 
     return {"success": True, "message": "HMS action executed"}
     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
             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
                 32-bit `print_error` path, 16 chars for the 64-bit `hms[]` path
                 (HMSError.full_code). Carried through unchanged from the route.
                 (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.
             action: One of HMSAction's string values.
             job_id: The `subtask_id` snapshotted onto the HMSError at parse-time.
             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
         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.
         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
                 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():
         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(
             publish(
                 {
                 {
                     "print": {
                     "print": {
@@ -5481,8 +5497,8 @@ class BambuMQTTClient:
             )
             )
 
 
         def hms_stop():
         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(
             publish(
                 {
                 {
                     "print": {
                     "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(
             publish(
                 {
                 {
                     "print": {
                     "print": {
                         "command": "idle_ignore",
                         "command": "idle_ignore",
-                        "err": print_error,
+                        "err": err_decimal,
                         "type": 1 if persistent else 0,
                         "type": 1 if persistent else 0,
                         "sequence_id": "0",
                         "sequence_id": "0",
                     }
                     }
@@ -5577,11 +5610,20 @@ class BambuMQTTClient:
             case HMSAction.STOP_PRINTING:
             case HMSAction.STOP_PRINTING:
                 hms_stop()
                 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:
             case HMSAction.FILAMENT_EXTRUDED | HMSAction.DBL_CHECK_DONE:
                 ams_control("done")
                 ams_control("done")

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

@@ -1770,24 +1770,26 @@ class TestExecuteHMSActionAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_execute_hms_action_success(self, async_client: AsyncClient, printer_factory):
     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")
         printer = await printer_factory(name="Test Printer")
 
 
         mock_client = MagicMock()
         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.state = "PAUSE"
         mock_client.state.print_error = 0x05008051
         mock_client.state.print_error = 0x05008051
         mock_client.state.hms_errors = [object()]
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
 
 
         def _act(*_a, **_kw):
         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
             return True
 
 
         mock_client.execute_hms_action.side_effect = _act
         mock_client.execute_hms_action.side_effect = _act
@@ -1830,8 +1832,8 @@ class TestExecuteHMSActionAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_execute_hms_action_no_printer_ack_returns_502(self, async_client: AsyncClient, printer_factory):
     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
         identifies: the broker ACKs the publish at QoS 1 but the firmware
         drops the command (err mismatch, wrong shape, state mismatch).
         drops the command (err mismatch, wrong shape, state mismatch).
         Surfacing this as 502 instead of 200 stops the UI from claiming
         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.state = "PAUSE"
         mock_client.state.print_error = 0x05008051
         mock_client.state.print_error = 0x05008051
         mock_client.state.hms_errors = [object()]
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
         mock_client.execute_hms_action.return_value = True  # publish "succeeded"
         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 (
         with (
             patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
             patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
@@ -1858,6 +1861,49 @@ class TestExecuteHMSActionAPI:
             assert response.status_code == 502
             assert response.status_code == 502
             assert "acknowledge" in response.json()["detail"].lower()
             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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_execute_hms_action_accepts_16_char_full_code(self, async_client: AsyncClient, printer_factory):
     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.state = "RUNNING"
         mock_client.state.print_error = 0
         mock_client.state.print_error = 0
         mock_client.state.hms_errors = [object()]
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
 
 
         def _act(*_a, **_kw):
         def _act(*_a, **_kw):
-            mock_client.state.hms_errors = []
+            mock_client._last_message_time = 100.4
             return True
             return True
 
 
         mock_client.execute_hms_action.side_effect = _act
         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 "err" not in cmds[0]["print"]
         assert "job_id" 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.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)
         cmds = self._published_commands(client)
         assert cmds[0] == {
         assert cmds[0] == {
             "print": {
             "print": {
-                "command": "resume",
-                "param": "",
+                "command": "ignore",
+                "err": str(0x05008051),  # "83918929"
+                "param": "reserve",
+                "job_id": "task-7",
                 "sequence_id": "0",
                 "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.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)
         cmds = self._published_commands(client)
         assert cmds[0] == {
         assert cmds[0] == {
             "print": {
             "print": {
                 "command": "idle_ignore",
                 "command": "idle_ignore",
-                "err": "03008070",
+                "err": str(0x03008070),
                 "type": 0,
                 "type": 0,
                 "sequence_id": "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.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)
         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)
         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):
     def test_filament_extruded_sends_ams_done(self, client):
         client.execute_hms_action("07008029", HMSAction.FILAMENT_EXTRUDED)
         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\'.',
   '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) {
   switch (severity) {
     case 1:
     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:
     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:
     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:
     case 4:
     default:
     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">
             <div className="space-y-3">
               {knownErrors.map((error, index) => {
               {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 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');
                 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>
                         <p className="text-sm text-bambu-gray mb-2">{description}</p>
                         {error.actions && error.actions.length > 0 && (
                         {error.actions && error.actions.length > 0 && (
                           <div className="flex flex-wrap gap-2 my-2">
                           <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>
                           </div>
                         )}
                         )}
                         <a
                         <a

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BCPW417s.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-CFA6-beJ.js


+ 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-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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio