fix(printers): surface the printer's own "command verification failed"

A P1S on firmware 01.10.00.00 rejected every control command and said so:
HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy
received that, dropped it, and reported a healthy printer instead.

The frontend filtered it out. This code's meaning lives in attr's low half
(0500) and code's high half (0001), both of which the MMMM_EEEE short form
discards, so it collapsed to "0500_0007" — no catalog entry, no firmware
actions, and filterKnownHMSErrors drops uncatalogued action-less errors.
Catalog lookups now try full_code first, in both the description and the
filter, and errors matched that way display the four-group code the
printer's own screen shows. The remedy line is ours, not Bambu's: their
wiki says to update Studio or Handy, which does not apply to a print sent
from Bambuddy.

The developer-mode probe made it worse. It read anything that was not an
explicit refusal as confirmation, and this firmware answers the probe with
an empty result while refusing everything else — so an inference drawn
from a non-answer became "developer_mode: pass" in the support bundle of a
printer that had not accepted a command all day. The probe now has three
outcomes: explicit success enables, explicit verify-failure disables,
anything else stays unknown and the diagnostic reports skip.

The HMS is authoritative over that inference in both directions. It forces
developer_mode False when present, and clears back to unknown when the
printer stops reporting it, so enabling Developer Mode and restarting the
printer is picked up without restarting Bambuddy.

Dispatch no longer treats a refusal as a wedge. The watchdog latches the
HMS across both phases and fails the item on the first attempt naming the
code and the fix, rather than spending three uploads and 270s a lap to
arrive at a message about SD cards. The check runs after the active-state
exit in both phases, so a lingering HMS can never abort a print that is
visibly running.

Also: the "wrong or mis-cased serial number" hint no longer fires in the
moment after a reconnect. _report_messages_since_connect is reset by
_on_connect, so a reconnect landing microseconds before the staleness
check leaves it at 0 for reasons that have nothing to do with the serial —
this reporter's healthy printer was told to go check its serial 1 ms after
reconnecting.
This commit is contained in:
maziggy
2026-07-31 14:14:58 +02:00
parent 11dc612bc4
commit 5e2b7b53e6
22 changed files with 710 additions and 177 deletions
+1
View File
@@ -28,6 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Failure detection can now authenticate to a token-protected Obico ML API (#2733)** — Obico's `ml_api` container takes an optional `ML_API_TOKEN` environment variable; with it set, the container answers 401 to any detection request that doesn't carry that token, which is how you stop everything else on your network from using your inference server. Bambuddy never sent one, so the only way to use it was to remove the token from the server — a step the reporter had already taken for their Home Assistant setup and did not want to undo. **Settings → Failure Detection** now has an **ML API Token** field; leave it empty and requests go out exactly as before. Also worth knowing: this failed in the most confusing way possible, because Obico protects its detection endpoint but leaves its health endpoint open. Bambuddy's **Test** button pinged the open one, so it reported success against a server that was rejecting every real call, and detection just silently never fired. Test now checks both and says outright when a token is rejected, and the status card reports a rejected token as a rejected token rather than a bare HTTP error. Translated in all locales; wiki documents the token, the health-endpoint trap and how to recover from it.
### Fixed
- **A printer refusing Bambuddy's commands looked healthy, and its queue failed with the wrong advice (#2732, reporter @hennischd)** — Uploads succeeded, the printer echoed the job back, then sat idle for 270 seconds and the job was re-uploaded twice more before failing with a message about SD cards. Temperature changes returned success and did nothing. The connection diagnostic passed every check, and the support bundle said Developer Mode was on. **Root cause.** The printer was rejecting every control command and saying so: HMS `0500-0500-0001-0007`, "MQTT command verification failed" — the firmware's authorization check, which Bambu Lab documents Developer Mode as the way to disable. Nothing in Bambuddy connected that to anything. The error itself was received and then discarded by the frontend, because this code's meaning lives in bits that Bambuddy's short-code form throws away: it collapses to `0500_0007`, matches no catalog entry, and uncatalogued errors without firmware actions are filtered out of the badge count and the error list. Meanwhile the Developer Mode probe reads any response that isn't an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so Bambuddy inferred a healthy printer from a non-answer, and reported that inference as a passing diagnostic. **Fix.** HMS codes are now looked up by their full identifier before the short form, so this error survives to the screen, shows the four-group code the printer's own display shows, and carries the fix rather than Bambu's "update Studio or Handy" (which does not apply to a print sent from Bambuddy). The error is treated as authoritative about the printer's state: it sets Developer Mode to off regardless of what the probe concluded, which makes the diagnostic and the support bundle report the real situation, and it clears itself when the printer stops reporting it, so enabling Developer Mode and restarting is picked up without restarting Bambuddy. The probe no longer reads an inconclusive answer as confirmation — it reports what it knows, which for this firmware is nothing. A queue item whose command is rejected now fails on the first attempt naming the code and the fix, instead of spending three uploads and fifteen minutes of a farm's upload capacity to arrive at the wrong conclusion; a print that is visibly running is never touched, whatever HMS is lingering. Separately, the log hint suggesting a wrong or mis-cased serial number no longer fires in the moment after a reconnect, when the report counter it reads has just been reset and proves nothing — it cost this reporter a detour through their serial number on a printer whose serial was correct. Translated in all locales. Covered by tests for the code surviving the filter, the display form, the developer-mode override and its self-clearing, the inconclusive probe, first-attempt failure, the running-print guard and the suppressed hint.
- **Support bundles reported Obico as monitoring no printers when it was monitoring all of them (#2733)** — The bundle read the list of monitored printers with a parser that split on commas and treated an empty value as "none". The setting is a JSON array, and empty means *all printers* — which is the default. So a working default Obico setup showed `obico_enabled: false` against every printer in its own support bundle, sending anyone reading it down the wrong path. The bundle now parses the setting the way the detection service does, and takes the global on/off switch into account.
- **A slot on Generic PLA offered only one K profile, however many the printer held (#2710, reporter @tommyboy180)** — The printer's Flow Dynamics table held nine calibrations, all of them under Generic PLA; Configure AMS Slot offered exactly one, the one already bound to the slot, and after a slot reset it offered none at all. The only way to assign the others was Bambu Studio. **Root cause.** Two independent faults, both triggered by picking a built-in generic preset. First, K profiles are matched to the selected preset by filament ID, but matches on Bambu's generic IDs (`GFL99` for Generic PLA, `GFG99` for Generic PETG, and so on) were deliberately thrown away as too broad — and since the comparison already requires both sides to carry the *same* ID, that exclusion could only ever fire when the chosen preset was itself the generic one, which is precisely the case where the match is right. Second, the matcher read the leading "Generic" in "Generic PLA" as a manufacturer, and so demanded the word "Generic" appear in the K profile's name; no real profile has it, which killed the name-based fallback as well. The one profile that did show up came from an unrelated safety net that always surfaces the slot's active profile, and a reset slot has none — hence the empty list. **Fix.** A K profile whose filament ID equals the selected preset's now matches, generic or not: the printer keeps one calibration table per filament ID, so a slot on Generic PLA offers everything calibrated under Generic PLA, whatever the user named those entries — "Dark Brown", "Marble", "Glow" and the rest now all appear. "Generic" is no longer treated as a brand, so profiles still match by material when a printer reports no filament ID at all. Since no name-and-ID matcher can be perfect against profile names the user invents, the picker now also lists every remaining profile on the printer under **Other K profiles on this printer**, so a profile that exists can always be selected; picking one works exactly as before, because Bambuddy already realigns the slot's filament context to whichever profile is chosen. The same generic-ID rule now applies to the spool form's K profile suggestions, guarded so it can never cross materials — a PETG spool is never offered PLA calibrations — and so that a spool naming its own brand keeps its brand-specific suggestions. Translated in all locales. Covered by tests for the reporter's nine-profile printer, the reset slot, printers that send no filament ID, the other-profiles group and applying a profile from it, and by guards that a brand preset still does not sweep in generic profiles.
- **The print-complete photo showed the toolhead still printing, three minutes before the print ended (#2547, reporter @anthonyma94)** — The Discord photo caught the model mid-print with the head over it, instead of the finished print. **Root cause.** Bambuddy fired the photo the moment `layer_num` reached `total_layer_num`. That edge is the moment the printer *starts* its final layer, not the moment it finishes it: on the reporter's H2C it arrived at 92% with two minutes of print still to run, and the last layer took three minutes and seventeen seconds including a filament change. Worse, that trigger latched, which locked out both of the triggers that fire at a genuine end of print — so on printers that never report an end-of-print filament unload (H2C and A1 Mini confirmed) there was no way back to a correct photo. **Fix.** The last-layer trigger is gone. The photo is taken when the print reports itself finished, which is a signal every model sends and which lands after the toolhead has parked. Since the printer's own end G-code drops the plate about 100 mm just before that, Bambuddy now raises it back to just above the last printed layer, takes the photo, then lowers it again — restoring the framing asked for in #1145, #1397 and #1565. The plate move is an absolute Z to a height the toolhead occupied seconds earlier, so it stays inside the travel limits and keeps the nozzle above the part, and it is skipped outright unless Bambuddy can confirm the height belongs to this print: the sliced file is matched by print name, and its layer count is cross-checked against the layer count the printer reported. It is also skipped when another job is queued, when the printer has moved on, and when the new **Restore plate for finish photo** setting is off. Prints whose End G-code Bambuddy injected — SwapMod plate swaps and similar — are detected automatically and keep using a frame from during the print, since their model has left the bed by then (#1867); that frame now also refreshes through the final layer instead of freezing when it began. Prints that record a timelapse normally source the photo from the video's last frame and need no move at all, but when the video has not transferred in time — the usual outcome on P1-series — the live photo that ships in the notification now gets the same plate restore, so it is no longer a shot of an empty-looking lowered bed. The `stg_cur=22` trigger is left in place for any firmware that does emit it, but the bundle survey above still applies — in practice every model reaches the finish-state path. Translated in all locales; wiki updated. Covered by backend tests for the removed trigger, the two surviving ones, the plate move and every condition that suppresses it, and by frontend tests for the new setting.
+104 -3
View File
@@ -307,6 +307,19 @@ _HMS_USER_ACTION_CODES: frozenset[str] = frozenset(
}
)
# "MQTT command verification failed" — the printer's authorization/authentication
# protection (firmware >= 01.08.03.00beta / 01.08.05.00) rejecting a control
# command it could not verify. Queries (get_version, extrusion_cali_get,
# pushall) still answer, so the connection looks perfectly healthy while
# project_file, gcode_line and ams_change_filament are all silently dropped —
# which is exactly how it presents: uploads succeed, the printer echoes our
# subtask_id, then sits at IDLE forever (#2732).
#
# The 16-char form is load-bearing. This code's meaning lives in attr's low half
# (0500) and code's high half (0001); the MMMM_EEEE short code collapses it to
# "0500_0007", which matches nothing in any catalog.
HMS_MQTT_VERIFY_FAILED: str = "0500050000010007"
@dataclass
class KProfile:
@@ -852,6 +865,13 @@ class BambuMQTTClient:
self._dev_mode_probe_seq: str | None = None
self._dev_mode_probe_time: float = 0.0 # monotonic timestamp when probe was sent
self._dev_mode_probe_failures: int = 0 # consecutive unanswered probes
# True while developer_mode=False came from HMS_MQTT_VERIFY_FAILED rather
# than from the probe or the "fun" bit. The HMS is a latch, not a level:
# the printer reports it until the fault clears, so when a later hms[]
# arrives without it (user enabled Developer Mode and restarted the
# printer) we drop back to "unknown" and let the probe re-run instead of
# leaving a permanently-wrong False behind (#2732).
self._dev_mode_from_hms: bool = False
self._connect_time: float = 0.0 # monotonic timestamp of last _on_connect
# Set when check_staleness() force-closes the socket to trigger reconnect.
@@ -970,7 +990,19 @@ class BambuMQTTClient:
# regardless, but the printer publishes to device/<real-serial>/
# report, which is case-sensitive. Surface that once so the user
# has something actionable instead of an endless reconnect loop.
if self._report_messages_since_connect == 0 and not self._zero_report_hint_logged:
# Only meaningful once the *current* session has had time to receive
# something. _report_messages_since_connect is reset by _on_connect,
# so a reconnect that lands microseconds before this check leaves it
# at 0 for reasons that have nothing to do with the serial — which is
# how a healthy P1S ended up being told to go check its serial number
# 1 ms after reconnecting (#2732). Requiring STALE_TIMEOUT of silence
# on this session means the hint only fires when the printer really
# has published nothing to the topic we subscribed to.
# _connect_time of 0 means we have no timestamp to judge by (never went
# through _on_connect); fall back to the old unconditional behaviour
# rather than silently swallowing the hint.
session_too_young = self._connect_time > 0 and (time.monotonic() - self._connect_time) < self.STALE_TIMEOUT
if self._report_messages_since_connect == 0 and not session_too_young and not self._zero_report_hint_logged:
self._zero_report_hint_logged = True
logger.warning(
"[%s] Connected and subscribed, but the printer has sent zero "
@@ -3747,6 +3779,7 @@ class BambuMQTTClient:
hms_list = data["hms"]
logger.debug("[%s] HMS data received: %s", self.serial_number, hms_list)
self.state.hms_errors = []
verify_failed = False
if isinstance(hms_list, list):
for hms in hms_list:
if isinstance(hms, dict):
@@ -3782,6 +3815,8 @@ class BambuMQTTClient:
# discards — that's the firmware's matching key, so try it
# first and fall back to the short form.
full_code = f"{attr:08X}{code:08X}"
if full_code == HMS_MQTT_VERIFY_FAILED:
verify_failed = True
actions = get_actions_for_error_code(self.serial_number[:3], full_code)
if not actions:
actions = get_actions_for_error_code(self.serial_number[:3], short_code.replace("_", ""))
@@ -3796,6 +3831,7 @@ class BambuMQTTClient:
full_code=full_code,
)
)
self._apply_mqtt_verify_state(verify_failed)
# Parse print_error - this is a different error format than HMS
# print_error is a 32-bit integer where:
@@ -4496,10 +4532,64 @@ class BambuMQTTClient:
logger.info("[%s] Probing developer mode via ams_filament_setting (seq=%s)", self.serial_number, seq)
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
def _apply_mqtt_verify_state(self, verify_failed: bool) -> None:
"""Reconcile developer_mode with the printer's own command-verification verdict.
``HMS_MQTT_VERIFY_FAILED`` is the only *direct* evidence we ever get that
control commands are being refused, so it outranks the probe in both
directions:
* present → developer_mode is definitively False, whatever the probe
concluded. The probe can only read the response to its own
``ams_filament_setting``; on P1 firmware a refusal is reported here
instead, so the probe answers ENABLED while every print silently dies
(#2732).
* gone again → drop the HMS-derived False back to unknown and re-arm the
probe, so a user who enables Developer Mode and restarts the printer
isn't stuck behind a verdict nothing would ever revisit.
A False that came from the probe or the ``fun`` bit is left alone — this
only ever unwinds its own latch.
"""
if verify_failed:
if not self._dev_mode_from_hms:
logger.warning(
"[%s] Printer reported HMS %s (MQTT command verification failed): it is "
"rejecting control commands, so prints, temperature changes and filament "
"loads will be ignored. Enable Developer Mode on the printer and restart it.",
self.serial_number,
HMS_MQTT_VERIFY_FAILED,
)
self._dev_mode_from_hms = True
self.state.developer_mode = False
return
if not self._dev_mode_from_hms:
return
logger.info(
"[%s] HMS %s cleared — re-probing developer mode",
self.serial_number,
HMS_MQTT_VERIFY_FAILED,
)
self._dev_mode_from_hms = False
self.state.developer_mode = None
self._dev_mode_probed = False
self._dev_mode_needs_probe = False
def _handle_dev_mode_probe_response(self, data: dict):
"""Handle response to the developer mode probe command.
Sets developer_mode based on whether the printer accepted or rejected the command.
Three outcomes, not two. An explicit ``success`` proves commands are
accepted and an explicit verify-failure proves they are not, but anything
else proves nothing — P1S firmware 01.10.00.00 answers this probe with a
bare ``{"command": "ams_filament_setting", "sequence_id": "3"}`` and no
``result`` at all, while refusing every control command and reporting
``HMS_MQTT_VERIFY_FAILED`` instead. Reading that empty response as ENABLED
is what put ``developer_mode: pass`` in the support bundle of a printer
that had not accepted a command all day (#2732). Leaving it unknown makes
the connection diagnostic report ``skip``, which is the honest answer.
"""
self._dev_mode_probe_seq = None # One-shot: don't match future responses
self._dev_mode_probe_failures = 0 # Reset on any response
@@ -4509,10 +4599,21 @@ class BambuMQTTClient:
if result == "failed" and "verify failed" in reason:
self.state.developer_mode = False
logger.info("[%s] Developer mode probe: DISABLED (reason=%r)", self.serial_number, reason)
else:
# Success or any other response — commands are accepted
elif str(result).lower() == "success":
self.state.developer_mode = True
logger.info("[%s] Developer mode probe: ENABLED (result=%r)", self.serial_number, result)
else:
# An HMS verdict already recorded here is real evidence; don't let an
# inconclusive probe response wipe it back to unknown.
if not self._dev_mode_from_hms:
self.state.developer_mode = None
logger.info(
"[%s] Developer mode probe: INCONCLUSIVE (result=%r, reason=%r) — "
"the printer neither confirmed nor refused the command",
self.serial_number,
result,
reason,
)
if self.on_state_change:
self.on_state_change(self.state)
+83 -2
View File
@@ -32,6 +32,7 @@ from backend.app.services.bambu_ftp import (
upload_file_async,
with_ftp_retry,
)
from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@@ -174,6 +175,25 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
def _mqtt_commands_rejected(status) -> bool:
"""True when the printer is currently reporting that it refused a command.
``HMS_MQTT_VERIFY_FAILED`` means the firmware's authorization check rejected
a control command it could not verify. Queries still answer, so the printer
looks connected and idle while project_file, gcode_line and
ams_change_filament are all dropped — no amount of waiting or re-uploading
changes that (#2732).
Tolerates a missing status and errors without a ``full_code`` (the 8-char
``print_error`` path builds HMSError differently), so this is safe to call on
every watchdog poll.
"""
for err in getattr(status, "hms_errors", None) or []:
if getattr(err, "full_code", "") == HMS_MQTT_VERIFY_FAILED:
return True
return False
def _installed_nozzle_diameters(status) -> list[float]:
"""Parse the installed nozzle diameters from a PrinterState (#1899).
@@ -2967,6 +2987,7 @@ class PrintScheduler:
queue_item_id: int,
printer_id: int,
created_by_id: int | None,
reason: str = "Printer accepted the file but never started printing",
) -> None:
"""Tell the user the queue item was failed after exhausting its dispatch retries.
@@ -2974,6 +2995,10 @@ class PrintScheduler:
its own — hence the fresh one here. Best-effort throughout: the row is
already marked failed and that is the load-bearing part; a notification
provider being down must not resurrect the retry loop we just stopped.
``reason`` defaults to the exhausted-retries wording. The command-rejected
path passes its own, because "accepted the file but never started" is the
opposite of what happened there — the printer refused it outright (#2732).
"""
try:
async with async_session() as db:
@@ -2986,7 +3011,7 @@ class PrintScheduler:
job_name=job_name,
printer_id=printer_id,
printer_name=printer.name if printer else "Unknown",
reason="Printer accepted the file but never started printing",
reason=reason,
db=db,
)
except Exception as e:
@@ -3857,9 +3882,20 @@ class PrintScheduler:
Phase A timeout raised from 45 s → 90 s as belt-and-braces for slow
transitions that also don't emit an early subtask_id tick.
Both phases also watch for ``HMS_MQTT_VERIFY_FAILED``. A printer that
refuses to verify our commands will never start this job or any other,
so waiting out the full 270 s and re-uploading the 3MF twice more only
burns an upload slot the rest of the farm is queued behind — that path
is for a printer that might still come good, which this one cannot
(#2732). It fails the item on the spot with the actual reason instead.
"""
last_status = None
landed_on_subtask = False
# Latched, not level-tested: state.hms_errors is rebuilt from scratch on
# every push carrying an `hms` key, so the fault can come and go between
# 3-second polls. Seeing it once inside the dispatch window is enough.
command_rejected = False
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
await asyncio.sleep(poll_interval)
@@ -3890,6 +3926,13 @@ class PrintScheduler:
except Exception:
pass
return
# Checked only after the active-state exit above: a stale HMS left
# over from an earlier job must never abort a print that is visibly
# running. An actually-refused command leaves the printer idle, so
# this ordering costs the detection nothing.
if _mqtt_commands_rejected(status):
command_rejected = True
break
if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
# Phase A exit — printer accepted the file (subtask_id flipped
# to our submission id). Don't return yet: the printer may
@@ -3899,7 +3942,7 @@ class PrintScheduler:
landed_on_subtask = True
break
if landed_on_subtask:
if landed_on_subtask and not command_rejected:
phase_b_deadline = time.monotonic() + phase_b_timeout
while time.monotonic() < phase_b_deadline:
await asyncio.sleep(poll_interval)
@@ -3919,6 +3962,11 @@ class PrintScheduler:
except Exception:
pass
return
# Same ordering rule as Phase A: a running print wins over a
# lingering HMS.
if _mqtt_commands_rejected(status):
command_rejected = True
break
# No active-state transition. Revert the item so the scheduler can retry.
# Drop the in-memory hold so the retry isn't blocked by it.
@@ -3948,6 +3996,20 @@ class PrintScheduler:
return "already_moved_on"
item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
item.started_at = None
if command_rejected:
# No retry budget for this one: the printer refused to verify the
# command, and re-uploading the same 3MF to the same printer will
# be refused the same way. Fail now with the fix rather than after
# three laps of a message about SD cards (#2732).
item.status = "failed"
item.error_message = (
"The printer rejected the print command: MQTT command verification failed "
"(HMS 0500-0500-0001-0007). Enable Developer Mode on the printer, restart it, "
"then start the job again."
)
item.completed_at = datetime.now(timezone.utc)
await db.commit()
return "command_rejected"
if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
item.status = "failed"
item.error_message = (
@@ -3982,6 +4044,25 @@ class PrintScheduler:
return
total_timeout = timeout + (phase_b_timeout if landed_on_subtask else 0.0)
if revert_outcome == "command_rejected":
logger.error(
"Queue item %s: printer %d reported HMS %s (MQTT command verification "
"failed) — the print command was rejected, not lost. Failing the item "
"without retrying; enable Developer Mode on the printer and restart it (#2732)",
queue_item_id,
printer_id,
HMS_MQTT_VERIFY_FAILED,
)
await scheduler._notify_dispatch_gave_up(
queue_item_id,
printer_id,
created_by_id,
reason="Printer rejected the print command (MQTT command verification failed)",
)
# Same reasoning as the landed_on_subtask path below: the file is on
# the printer and a forced reconnect would only add 0500_4003 to a
# problem that has nothing to do with the MQTT session (#1150).
return
if revert_outcome == "gave_up":
logger.error(
"Queue item %s: printer %d never started the print after %d dispatch "
@@ -3547,6 +3547,108 @@ class TestDeveloperModeDetection:
assert mqtt_client.state.developer_mode is False
class TestMqttCommandVerificationFailed:
"""HMS 0500_0500_0001_0007 is the printer refusing to verify our commands (#2732).
A P1S on firmware 01.10.00.00 answers queries normally while dropping every
control command, so nothing else in the connection looks wrong. This HMS is
the only direct evidence, which makes it authoritative over the probe.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="01S00A000000000",
access_code="12345678",
)
@staticmethod
def _hms_payload(*entries):
return {"print": {"gcode_state": "IDLE", "hms": list(entries)}}
# attr 0x05000500, code 0x00010007 — the values a real P1S sends.
VERIFY_FAILED = {"attr": 83887360, "code": 65543}
OTHER_FAULT = {"attr": 0x03000200, "code": 0x00018012}
def test_hms_forces_developer_mode_false(self, mqtt_client):
mqtt_client.state.developer_mode = True # what the probe wrongly concluded
mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
assert mqtt_client.state.developer_mode is False
def test_hms_is_surfaced_with_its_full_code(self, mqtt_client):
"""The short code collapses to a useless 0500_0007 — full_code must survive."""
from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
assert [e.full_code for e in mqtt_client.state.hms_errors] == [HMS_MQTT_VERIFY_FAILED]
def test_unrelated_hms_does_not_touch_developer_mode(self, mqtt_client):
mqtt_client.state.developer_mode = True
mqtt_client._process_message(self._hms_payload(self.OTHER_FAULT))
assert mqtt_client.state.developer_mode is True
def test_clearing_the_hms_re_arms_the_probe(self, mqtt_client):
"""Enabling Developer Mode and restarting must not leave a stuck False."""
mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
assert mqtt_client.state.developer_mode is False
mqtt_client._dev_mode_probed = True
mqtt_client._process_message(self._hms_payload())
assert mqtt_client.state.developer_mode is None
assert mqtt_client._dev_mode_probed is False
def test_empty_hms_leaves_a_probe_verdict_alone(self, mqtt_client):
"""Only the HMS-derived latch self-clears; a probe's False is not ours to undo."""
mqtt_client.state.developer_mode = False # from an explicit probe refusal
mqtt_client._process_message(self._hms_payload())
assert mqtt_client.state.developer_mode is False
def test_inconclusive_probe_does_not_overwrite_the_hms_verdict(self, mqtt_client):
mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
assert mqtt_client.state.developer_mode is False
class TestDeveloperModeProbeInconclusive:
"""An empty probe response proves nothing and must not read as ENABLED (#2732)."""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
def test_empty_result_stays_unknown(self, mqtt_client):
"""P1S 01.10.00.00 echoes the command back with no `result` field at all."""
mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
assert mqtt_client.state.developer_mode is None
def test_explicit_success_still_enables(self, mqtt_client):
mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": "success"})
assert mqtt_client.state.developer_mode is True
def test_verify_failure_still_disables(self, mqtt_client):
mqtt_client._handle_dev_mode_probe_response(
{"sequence_id": "3", "result": "failed", "reason": "mqtt message verify failed"}
)
assert mqtt_client.state.developer_mode is False
def test_inconclusive_response_still_clears_probe_bookkeeping(self, mqtt_client):
"""Whatever the verdict, the response ends the probe (no retry storm)."""
mqtt_client._dev_mode_probe_seq = "3"
mqtt_client._dev_mode_probe_failures = 1
mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": ""})
assert mqtt_client._dev_mode_probe_seq is None
assert mqtt_client._dev_mode_probe_failures == 0
class TestDeveloperModeProbeTimeout:
"""Tests for developer mode probe timeout, retry, and forced reconnect (#887).
@@ -4670,6 +4772,44 @@ class TestStaleReconnect:
mqtt_client.check_staleness()
assert not any("zero status reports" in r.getMessage() for r in caplog.records)
def test_check_staleness_no_serial_hint_right_after_reconnect(self, mqtt_client, caplog):
"""#2732 — _report_messages_since_connect is reset by _on_connect, so a
reconnect landing just before the staleness check leaves it at 0 for
reasons that have nothing to do with the serial. A healthy P1S was being
told to check its serial number 1 ms after reconnecting."""
import logging
import time
mqtt_client.state.connected = True
mqtt_client._last_message_time = time.time() - 120
mqtt_client._report_messages_since_connect = 0
mqtt_client._connect_time = time.monotonic() # fresh session
with caplog.at_level(logging.WARNING):
mqtt_client.check_staleness()
assert mqtt_client._zero_report_hint_logged is False
assert not any("zero status reports" in r.getMessage() for r in caplog.records)
# The stale reconnect itself still happens — only the hint is suppressed.
assert mqtt_client._stale_reconnecting is True
def test_check_staleness_serial_hint_when_session_old_enough(self, mqtt_client, caplog):
"""A session that has been up past the stale window and still received
nothing is the case the hint was written for."""
import logging
import time
mqtt_client.state.connected = True
mqtt_client._last_message_time = time.time() - 120
mqtt_client._report_messages_since_connect = 0
mqtt_client._connect_time = time.monotonic() - 120
with caplog.at_level(logging.WARNING):
mqtt_client.check_staleness()
assert mqtt_client._zero_report_hint_logged is True
assert any("zero status reports" in r.getMessage() for r in caplog.records)
def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog):
"""A stale connection that DID receive reports (a normal mid-session
quiet gap) must not log the serial-number hint."""
@@ -607,3 +607,118 @@ class TestWatchdogRetryBudget:
item = await db.get(PrintQueueItem, 1)
assert item.status == "printing"
assert item.dispatch_attempts == 0
class TestWatchdogCommandRejected:
"""A printer reporting HMS 0500_0500_0001_0007 refused the command outright.
It is not wedged and it is not slow: its authorization check rejected a
command it could not verify, and it will reject the next two identically.
Spending the full 270 s and two more full 3MF uploads on that is 15 minutes
of a farm's upload capacity buying nothing, and it ends with a message about
SD cards (#2732).
"""
@staticmethod
def _rejected_status(state: str = "IDLE", subtask_id: str | None = "NEW_SUBTASK"):
from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
return SimpleNamespace(
state=state,
subtask_id=subtask_id,
gcode_file="/new.3mf",
hms_errors=[SimpleNamespace(full_code=HMS_MQTT_VERIFY_FAILED)],
)
@staticmethod
async def _run(db_session, status):
get_status = MagicMock(return_value=status)
get_client = MagicMock(return_value=MagicMock())
with (
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
patch("backend.app.services.print_scheduler.async_session", db_session),
patch("backend.app.core.database.async_session", db_session),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_failed",
AsyncMock(),
) as notify,
):
await PrintScheduler._watchdog_print_start(
queue_item_id=1,
printer_id=42,
pre_state="IDLE",
pre_subtask_id="OLD_SUBTASK",
pre_gcode_file="/old.3mf",
timeout=0.2,
phase_b_timeout=0.2,
poll_interval=0.05,
)
return get_client, notify
@pytest.mark.asyncio
async def test_fails_on_the_first_attempt(self, db_session):
await self._run(db_session, self._rejected_status())
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert item.status == "failed", "a refused command must not be retried"
assert item.dispatch_attempts == 1, "it must not burn the whole budget"
assert item.completed_at is not None
@pytest.mark.asyncio
async def test_error_message_names_the_fix(self, db_session):
"""The old wording sent this user to check their SD card."""
await self._run(db_session, self._rejected_status())
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert "0500-0500-0001-0007" in item.error_message
assert "Developer Mode" in item.error_message
assert "SD card" not in item.error_message
@pytest.mark.asyncio
async def test_detected_in_phase_a_before_any_subtask_advance(self, db_session):
"""The printer can refuse without ever echoing a subtask_id."""
await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert item.status == "failed"
assert item.dispatch_attempts == 1
@pytest.mark.asyncio
async def test_skips_the_forced_reconnect(self, db_session):
"""The MQTT session is fine — reconnecting would only add 0500_4003 (#1150)."""
get_client, _ = await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
get_client.assert_not_called()
@pytest.mark.asyncio
async def test_notifies_with_the_rejection_reason(self, db_session):
_, notify = await self._run(db_session, self._rejected_status())
notify.assert_awaited_once()
assert "rejected" in notify.await_args.kwargs["reason"]
@pytest.mark.asyncio
async def test_unrelated_hms_still_takes_the_retry_path(self, db_session):
"""Only this code short-circuits; every other fault keeps its retries."""
status = self._rejected_status()
status.hms_errors = [SimpleNamespace(full_code="0300020000018012")]
await self._run(db_session, status)
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert item.status == "pending"
assert item.dispatch_attempts == 1
@pytest.mark.asyncio
async def test_a_printer_that_actually_starts_is_unaffected(self, db_session):
"""A stale HMS from a previous job must not kill a print that is running."""
await self._run(db_session, self._rejected_status(state="RUNNING"))
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert item.status == "printing"
assert item.dispatch_attempts == 0
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { HMSErrorModal } from '../../components/HMSErrorModal';
import { HMSErrorModal, filterKnownHMSErrors } from '../../components/HMSErrorModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import type { HMSError } from '../../api/client';
@@ -189,6 +189,50 @@ describe('HMSErrorModal', () => {
});
});
describe('MQTT command verification failed (#2732)', () => {
// attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00.
// getShortCode() collapses this to "0500_0007", which matches nothing, so
// before #2732 filterKnownHMSErrors dropped the one error that explained
// why the printer accepted every job and started none of them.
const verifyFailedError: HMSError = {
attr: 0x05000500,
code: '0x10007',
severity: 1,
full_code: '0500050000010007',
};
it('surfaces the error instead of filtering it out', () => {
render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
expect(screen.queryByText('No errors')).not.toBeInTheDocument();
expect(screen.getByText(/could not verify it/i)).toBeInTheDocument();
});
it('counts towards the known-error filter', () => {
expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1);
});
it('shows the remedy, not just the fault', () => {
render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument();
});
it('displays the code the printer screen shows, not the truncated form', () => {
render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument();
expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument();
});
it('leaves short-code errors on the two-group display', () => {
render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
expect(screen.getByText('[0300-400C]')).toBeInTheDocument();
});
it('does not add the remedy line to other errors', () => {
render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('calls onClose when X button is clicked', async () => {
const user = userEvent.setup();
+43 -5
View File
@@ -33,9 +33,20 @@ const AMS_RUNOUT_SHORT_CODES = new Set([
'0705_8011', '0706_8011', '0707_8011', '07FF_8011',
]);
// Comprehensive error code database (short format: XXXX_YYYY)
// Auto-generated from ha-bambulab - 853 codes
// "MQTT command verification failed" — the firmware's authorization check
// refusing a control command. Keyed by its full 16-char code on purpose: this
// error's meaning lives in attr's low half (0500) and code's high half (0001),
// both of which getShortCode() discards, so the short form is a useless
// "0500_0007". Before #2732 that meant filterKnownHMSErrors dropped it and the
// user was never shown the one message that explained why nothing printed.
export const HMS_MQTT_VERIFY_FAILED = '0500050000010007';
// Comprehensive error code database, keyed by short code (XXXX_YYYY) or, where
// the short code cannot express the error, by full code (16 hex chars).
// Short-code entries auto-generated from ha-bambulab - 853 codes
const ERROR_DESCRIPTIONS: Record<string, string> = {
[HMS_MQTT_VERIFY_FAILED]:
'The printer rejected a command because it could not verify it. Prints, temperature changes and filament loads sent from Bambuddy will be ignored until this is fixed.',
'0300_4000': 'Z axis homing failed; the task has been stopped.',
'0300_4001': 'The printer timed out waiting for the nozzle to cool down before homing.',
'0300_4002': 'Auto Bed Leveling failed; the task has been stopped.',
@@ -913,6 +924,15 @@ function getShortCode(attr: number, code: number): string {
return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;
}
// Catalog lookup. full_code (16 hex chars for hms[]-sourced faults) is tried
// first because it is lossless; shortCode is the fallback that the bulk of the
// catalog is keyed by. Returns undefined for an uncataloged error so callers can
// tell "no description" from "empty description".
function lookupDescription(fullCode: string | undefined, shortCode: string): string | undefined {
if (fullCode && ERROR_DESCRIPTIONS[fullCode] !== undefined) return ERROR_DESCRIPTIONS[fullCode];
return ERROR_DESCRIPTIONS[shortCode];
}
// Helper to filter HMS errors the UI should surface (exported for use in badge counts).
// Keeps an error if EITHER:
// - it's in the bundled ERROR_DESCRIPTIONS catalog (known, has a description), OR
@@ -925,7 +945,7 @@ export function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {
return errors.filter((error) => {
const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
const shortCode = getShortCode(error.attr, codeNum);
if (ERROR_DESCRIPTIONS[shortCode] !== undefined) return true;
if (lookupDescription(error.full_code, shortCode) !== undefined) return true;
return (error.actions?.length ?? 0) > 0;
});
}
@@ -1023,7 +1043,17 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
// 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');
const matchedFullCode =
!!error.full_code && ERROR_DESCRIPTIONS[error.full_code] !== undefined;
let description =
lookupDescription(error.full_code, shortCode) ?? t('hmsErrors.unknownCode');
// The remedy is Bambuddy's, not Bambu's — their wiki says "update
// Studio or Handy", which is no help to someone printing from
// Bambuddy. Same override shape as the runout guidance below.
const remedy =
error.full_code === HMS_MQTT_VERIFY_FAILED
? t('hmsErrors.mqttVerifyFailedRemedy')
: null;
if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
description = t('hmsErrors.runoutExpectedSlot', {
@@ -1039,7 +1069,14 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
}
}
const hmsHomeUrl = getHMSHomeUrl();
const displayCode = shortCode.replace('_', '-');
// Show the printer's own four-group notation when the short code
// could not identify the error — for those, "0500-0007" matches
// nothing the user can look up, while "0500-0500-0001-0007" is
// exactly what the printer screen and the Bambu wiki show.
const displayCode =
matchedFullCode && error.full_code!.length === 16
? error.full_code!.match(/.{4}/g)!.join('-')
: shortCode.replace('_', '-');
return (
<div
@@ -1056,6 +1093,7 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
</span>
</div>
<p className="text-sm text-bambu-gray mb-2">{description}</p>
{remedy && <p className="text-sm text-bambu-gray mb-2">{remedy}</p>}
{error.actions && error.actions.length > 0 && (
<div className="flex flex-wrap gap-2 my-2">
{error.actions.map((action) => {
+1
View File
@@ -2837,6 +2837,7 @@ export default {
title: 'Fehler - {{name}}',
noErrors: 'Keine Fehler',
viewOnWiki: 'Im Bambu Lab Wiki ansehen',
mqttVerifyFailedRemedy: 'Aktiviere den Entwicklermodus am Drucker (Einstellungen > Allgemein), starte den Drucker neu und starte den Auftrag dann erneut.',
unknownCode: 'Unbekannter HMS-Code — Details siehe Bambu Lab Wiki.',
clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
clearErrors: 'Fehler löschen',
+1
View File
@@ -2866,6 +2866,7 @@ export default {
title: 'Errors - {{name}}',
noErrors: 'No errors',
viewOnWiki: 'View on Bambu Lab Wiki',
mqttVerifyFailedRemedy: 'Enable Developer Mode on the printer (Settings > General), restart the printer, then start the job again.',
unknownCode: 'Unknown HMS code — see the Bambu Lab wiki for details.',
clearInstructions: 'Clear errors on the printer to dismiss them here.',
clearErrors: 'Clear Errors',
+1
View File
@@ -2840,6 +2840,7 @@ export default {
title: 'Errores - {{name}}',
noErrors: 'No hay errores',
viewOnWiki: 'Ver en la wiki de Bambu Lab',
mqttVerifyFailedRemedy: 'Activa el modo desarrollador en la impresora (Ajustes > General), reinicia la impresora y vuelve a iniciar el trabajo.',
unknownCode: 'Código HMS desconocido — consulta la wiki de Bambu Lab para más detalles.',
clearInstructions: 'Borre los errores en la impresora para descartarlos aquí.',
clearErrors: 'Borrar errores',
+1
View File
@@ -2826,6 +2826,7 @@ export default {
title: 'Erreurs - {{name}}',
noErrors: 'Aucune erreur',
viewOnWiki: 'Voir sur le Wiki Bambu Lab',
mqttVerifyFailedRemedy: "Activez le mode developpeur sur l'imprimante (Parametres > General), redemarrez l'imprimante, puis relancez la tache.",
unknownCode: 'Code HMS inconnu — consultez le wiki Bambu Lab pour plus de détails.',
clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
clearErrors: 'Effacer les erreurs',
+1
View File
@@ -2825,6 +2825,7 @@ export default {
title: 'Errori - {{name}}',
noErrors: 'Nessun errore',
viewOnWiki: 'Vedi su Bambu Lab Wiki',
mqttVerifyFailedRemedy: 'Attiva la modalita sviluppatore sulla stampante (Impostazioni > Generale), riavvia la stampante e avvia di nuovo il lavoro.',
unknownCode: 'Codice HMS sconosciuto — consulta la wiki di Bambu Lab per i dettagli.',
clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
clearErrors: 'Cancella errori',
+1
View File
@@ -2837,6 +2837,7 @@ export default {
title: 'エラー - {{name}}',
noErrors: 'エラーなし',
viewOnWiki: 'Bambu Lab Wikiで表示',
mqttVerifyFailedRemedy: 'プリンターで開発者モードを有効にし(設定 > 一般)、プリンターを再起動してから、ジョブをもう一度開始してください。',
unknownCode: '不明なHMSコード — 詳細はBambu Lab Wikiを参照してください。',
clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
clearErrors: 'エラーをクリア',
+1
View File
@@ -2687,6 +2687,7 @@ export default {
title: '오류 - {{name}}',
noErrors: '오류 없음',
viewOnWiki: 'Bambu Lab 위키에서 보기',
mqttVerifyFailedRemedy: '프린터에서 개발자 모드를 활성화하고(설정 > 일반) 프린터를 재시작한 다음 작업을 다시 시작하세요.',
unknownCode: '알 수 없는 HMS 코드 — 자세한 내용은 Bambu Lab 위키를 참조하세요.',
clearInstructions: '오류를 해제하려면 프린터에서 오류를 지우세요.',
clearErrors: '오류 지우기',
+1
View File
@@ -2825,6 +2825,7 @@ export default {
title: 'Erros - {{name}}',
noErrors: 'Nenhum erro',
viewOnWiki: 'Ver no Bambu Lab Wiki',
mqttVerifyFailedRemedy: 'Ative o Modo Desenvolvedor na impressora (Configuracoes > Geral), reinicie a impressora e inicie o trabalho novamente.',
unknownCode: 'Código HMS desconhecido — consulte o wiki da Bambu Lab para mais detalhes.',
clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',
clearErrors: 'Limpar Erros',
+1
View File
@@ -2679,6 +2679,7 @@ export default {
title: "Ошибки — {{name}}",
noErrors: "Ошибок нет",
viewOnWiki: "Открыть в Bambu Lab Wiki",
mqttVerifyFailedRemedy: "Включите режим разработчика на принтере (Настройки > Общие), перезагрузите принтер и запустите задание снова.",
unknownCode: "Неизвестный код HMS — подробности см. в Bambu Lab Wiki.",
clearInstructions: "Устраните ошибки на принтере, чтобы они исчезли из этого списка.",
clearErrors: "Очистить ошибки",
+1
View File
@@ -2841,6 +2841,7 @@ export default {
title: 'Hatalar - {{name}}',
noErrors: 'Hata yok',
viewOnWiki: 'Bambu Lab Wiki\'de görüntüle',
mqttVerifyFailedRemedy: 'Yazicida Gelistirici Modunu etkinlestirin (Ayarlar > Genel), yaziciyi yeniden baslatin ve isi tekrar baslatin.',
unknownCode: 'Bilinmeyen HMS kodu — ayrıntılar için Bambu Lab wiki sayfasına bakın.',
clearInstructions: 'Buradan kapatmak için yazıcıdaki hataları temizleyin.',
clearErrors: 'Hataları Temizle',
+1
View File
@@ -2866,6 +2866,7 @@ export default {
title: "Помилки - {{name}}",
noErrors: "Помилок немає",
viewOnWiki: "Переглянути на Bambu Lab Wiki",
mqttVerifyFailedRemedy: "Увімкніть режим розробника на принтері (Налаштування > Загальні), перезавантажте принтер і запустіть завдання знову.",
unknownCode: "Невідомий код HMS — подробиці дивіться у вікі Bambu Lab.",
clearInstructions: "Усуньте помилки на принтері, щоб вони зникли тут.",
clearErrors: "Очистити помилки",
+1
View File
@@ -2825,6 +2825,7 @@ export default {
title: '错误 - {{name}}',
noErrors: '无错误',
viewOnWiki: '在拓竹 Wiki 上查看',
mqttVerifyFailedRemedy: '在打印机上启用开发者模式(设置 > 通用),重启打印机,然后重新开始该任务。',
unknownCode: '未知 HMS 代码 — 详情请参阅拓竹 Wiki。',
clearInstructions: '在打印机上清除错误以在此处消除它们。',
clearErrors: '清除错误',
+1
View File
@@ -2825,6 +2825,7 @@ export default {
title: '錯誤 - {{name}}',
noErrors: '無錯誤',
viewOnWiki: '在拓竹 Wiki 上檢視',
mqttVerifyFailedRemedy: '在印表機上啟用開發者模式(設定 > 一般),重新啟動印表機,然後重新開始該工作。',
unknownCode: '未知 HMS 代碼 — 詳情請參閱拓竹 Wiki。',
clearInstructions: '在印表機上清除錯誤以在此處消除它們。',
clearErrors: '清除錯誤',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CNWKbw8h.js"></script>
<script type="module" crossorigin src="/assets/index-I83QBJfM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
</head>
<body>