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.
This commit is contained in:
maziggy
2026-06-29 10:59:29 +02:00
parent ee890a0ad3
commit a45d32efd0
9 changed files with 268 additions and 125 deletions
+15 -12
View File
@@ -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"}
+77 -35
View File
@@ -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.
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).
#
# 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.
#
# 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_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.IGNORE_NO_REMINDER_NEXT_TIME | HMSAction.DONT_REMIND_NEXT_TIME:
hms_ignore(persistent=True)
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")