|
|
@@ -40,6 +40,20 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
|
|
|
# printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
|
|
|
_ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
|
|
|
|
|
|
+# CONNACK reason codes that mean the printer actively refused our credentials,
|
|
|
+# as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
|
|
|
+# single-byte CONNACK return codes paho maps onto the v5 reason-code space:
|
|
|
+# return code 4 ("bad user name or password") -> 134, and 5 ("not authorized")
|
|
|
+# -> 135. Both mean the same thing in practice for a Bambu printer: the access
|
|
|
+# code (or, on some firmware, the serial used as the username) is wrong.
|
|
|
+_CONNACK_AUTH_REJECTED = frozenset({134, 135})
|
|
|
+
|
|
|
+# Short, stable slugs recorded on the client and surfaced to the connection
|
|
|
+# diagnostic as a `params.reason` variant. Deliberately not free text — the
|
|
|
+# frontend picks a localized message key off these.
|
|
|
+CONNECT_ERROR_AUTH_REJECTED = "auth_rejected"
|
|
|
+CONNECT_ERROR_REFUSED = "refused"
|
|
|
+
|
|
|
|
|
|
def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
|
|
|
"""Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
|
|
|
@@ -147,9 +161,11 @@ def apply_tray_exist_bits(
|
|
|
the HT keeps echoing stale ``tray_type`` and its ``state`` is firmware-variant
|
|
|
(#2670). Verified against OrcaSlicer ``DevFilaSystem.cpp``
|
|
|
(``is_exists = tray_exist_bits >> (16 + (ams_id-128))``) and a live H2D
|
|
|
- capture (HT-A → bit 16). The A2L-Lite (normalised to id 6 upstream) lands at
|
|
|
- bits 24-27 via the regular ``ams_id * 4`` formula, matching OrcaSlicer's
|
|
|
- ``AMS_LITE_MIXED`` offset, so it needs no special case here.
|
|
|
+ capture (HT-A → bit 16). The A2L-Lite lands at bits 24-27 via the regular
|
|
|
+ ``ams_id * 4`` formula, matching OrcaSlicer's ``AMS_LITE_MIXED`` offset; the
|
|
|
+ unit id is folded through ``normalize_am_unit_id`` first so callers holding
|
|
|
+ the raw physical id 16 get the same bit base as callers holding the
|
|
|
+ normalised 6 (#2697).
|
|
|
|
|
|
`tray_exist_bits_str` is expected as a hex string (firmware sends it that
|
|
|
way). Ints are tolerated for defensive symmetry but typically not seen
|
|
|
@@ -192,6 +208,13 @@ def apply_tray_exist_bits(
|
|
|
continue
|
|
|
if not isinstance(ams_id, int):
|
|
|
continue
|
|
|
+ # The A2L AMS-Lite reaches this helper under either id: `_handle_ams_data`
|
|
|
+ # normalises 16 -> 6 before calling, but the VP bridge parses the raw
|
|
|
+ # printer payload itself (`mqtt_bridge._on_printer_raw`) and still holds
|
|
|
+ # the physical 16. Both mean bit base 24, so fold them together here
|
|
|
+ # rather than relying on every caller to normalise first — reading 16 as
|
|
|
+ # 16*4 = bit 64 finds nothing set and wipes every A2L slot (#2697).
|
|
|
+ ams_id = normalize_am_unit_id(ams_id)
|
|
|
# AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
|
|
|
# Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
|
|
|
# Anything outside those ranges has no known bit layout — don't guess it.
|
|
|
@@ -284,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:
|
|
|
@@ -449,6 +485,21 @@ class PrinterState:
|
|
|
big_fan1_speed: int | None = None # Auxiliary fan
|
|
|
big_fan2_speed: int | None = None # Chamber/exhaust fan
|
|
|
heatbreak_fan_speed: int | None = None # Hotend heatbreak fan
|
|
|
+ # Left auxiliary part cooling fan (optional accessory on P2S/X2D). Reported ONLY
|
|
|
+ # via device.airduct.parts (decoded part id 10 = FAN_REMOTE_COOLING_1 in Bambu
|
|
|
+ # Studio's AIR_FUN enum) — the firmware does NOT mirror it into any flat
|
|
|
+ # big_fanX_speed field, which is why it was previously dropped. 0-100 percent.
|
|
|
+ left_aux_fan_speed: int | None = None
|
|
|
+ # Chamber exhaust fan, derived from the airduct parts list containing decoded
|
|
|
+ # id 3. On the P2S this is the External Exhaust Fan kit and a base machine
|
|
|
+ # omits it, which is the case this flag exists to detect.
|
|
|
+ #
|
|
|
+ # NOTE: the flag is not P2S/X2D-specific despite the name. The H2 series
|
|
|
+ # (H2C/H2D/H2S) also reports part 3, so this goes True there too. That is
|
|
|
+ # harmless because only the P2S/X2D badge consults it — those models keep
|
|
|
+ # their unconditional "Chamber Fan" badge — but do not read this as
|
|
|
+ # "an exhaust kit is fitted" without also checking the model.
|
|
|
+ exhaust_fan_present: bool = False
|
|
|
# Tray change history during current print: [(global_tray_id, layer_num), ...]
|
|
|
# Used by usage tracker to split filament weight on mid-print tray switch
|
|
|
tray_change_log: list = field(default_factory=list)
|
|
|
@@ -543,6 +594,58 @@ def get_stage_name(stage: int) -> str:
|
|
|
return STAGE_NAMES.get(stage, f"Unknown stage ({stage})")
|
|
|
|
|
|
|
|
|
+# #2547 end-of-print telemetry probe.
|
|
|
+#
|
|
|
+# The finish photo needs a "printing is done, toolhead parked, filament unload
|
|
|
+# not started yet" moment. ``stg_cur=22`` was meant to be that moment (#1721)
|
|
|
+# but fires on no model in the field: across 247 support bundles there is not a
|
|
|
+# single ``FINISH PHOTO MOMENT (stage-22)``, including the 2026-06-13..07-08
|
|
|
+# window where it was the only pre-FINISH trigger in the code (104 captures on
|
|
|
+# A1, A1 Mini, H2C, H2D, P1S, P2S, X1C, X2D — all of them the FINISH fallback).
|
|
|
+#
|
|
|
+# We can't design a replacement from bundles we already have, because out of
|
|
|
+# this window Bambuddy only ever parses ``stg_cur`` and ``mc_print_sub_stage``;
|
|
|
+# every other stage/action field is dropped unread. The obvious candidates
|
|
|
+# (``print_real_action``, ``mc_action``, ``mc_stage``) are also absent from
|
|
|
+# A1/A1 Mini/P1S payloads, so none of them can be the universal answer on its
|
|
|
+# own. Dumping the raw values for the window between the last object layer and
|
|
|
+# ``gcode_state=FINISH`` lets one debug bundle per model settle what — if
|
|
|
+# anything — marks that moment.
|
|
|
+#
|
|
|
+# Every field here is machine telemetry (stage codes, counters, bitfields).
|
|
|
+# Nothing identifying, and nothing that could carry an access code.
|
|
|
+_END_OF_PRINT_PROBE_FIELDS = (
|
|
|
+ "gcode_state",
|
|
|
+ "state",
|
|
|
+ "print_error",
|
|
|
+ "stg_cur",
|
|
|
+ "stg",
|
|
|
+ "stg_cd",
|
|
|
+ "mc_print_stage",
|
|
|
+ "mc_print_sub_stage",
|
|
|
+ "mc_action",
|
|
|
+ "mc_stage",
|
|
|
+ "print_real_action",
|
|
|
+ "print_gcode_action",
|
|
|
+ "spd_lvl",
|
|
|
+ "mc_percent",
|
|
|
+ "mc_remaining_time",
|
|
|
+ "layer_num",
|
|
|
+ "total_layer_num",
|
|
|
+ "home_flag",
|
|
|
+ "prepare_per",
|
|
|
+)
|
|
|
+
|
|
|
+# Frame budget for one print's probe. A long final layer can hold the window
|
|
|
+# open for minutes at ~1 frame/second; this stops a single print from filling
|
|
|
+# the log the user then has to upload.
|
|
|
+_END_OF_PRINT_PROBE_MAX_FRAMES = 400
|
|
|
+
|
|
|
+# States that close the window. FINISH is the interesting one — the probe's
|
|
|
+# whole job is to show what happened in the run-up to it.
|
|
|
+_END_OF_PRINT_PROBE_CLOSING_STATES = frozenset({"FINISH", "FAILED", "IDLE", "PREPARE"})
|
|
|
+
|
|
|
+
|
|
|
class BambuMQTTClient:
|
|
|
"""MQTT client for Bambu Lab printer communication."""
|
|
|
|
|
|
@@ -571,6 +674,7 @@ class BambuMQTTClient:
|
|
|
on_print_complete: Callable[[dict], None] | None = None,
|
|
|
on_ams_change: Callable[[list], None] | None = None,
|
|
|
on_layer_change: Callable[[int], None] | None = None,
|
|
|
+ on_print_progress: Callable[[int], None] | None = None,
|
|
|
on_bed_temp_update: Callable[[float], None] | None = None,
|
|
|
on_drying_complete: Callable[[int], None] | None = None,
|
|
|
on_print_running_observed: Callable[[dict], None] | None = None,
|
|
|
@@ -588,6 +692,13 @@ class BambuMQTTClient:
|
|
|
self.on_print_complete = on_print_complete
|
|
|
self.on_ams_change = on_ams_change
|
|
|
self.on_layer_change = on_layer_change
|
|
|
+ # #2547: fired when `mc_percent` advances during a running print.
|
|
|
+ # `on_layer_change` stops firing the instant the final layer starts, so
|
|
|
+ # it is blind to the last few percent of a print — which is exactly the
|
|
|
+ # window the finish-photo frame bank needs to keep refreshing through.
|
|
|
+ # Progress is the one field that keeps ticking there and then freezes
|
|
|
+ # before the end G-code runs, so banking on it stays inside the print.
|
|
|
+ self.on_print_progress = on_print_progress
|
|
|
self.on_bed_temp_update = on_bed_temp_update
|
|
|
# #1349: fired when an AMS unit's dry_time falls from >0 to 0 — i.e.
|
|
|
# the drying cycle just finished (auto- or manually-triggered).
|
|
|
@@ -646,6 +757,18 @@ class BambuMQTTClient:
|
|
|
# and the FINISH-state fallback don't both fire on the same
|
|
|
# print. Reset to False on every print start.
|
|
|
self._finish_photo_captured: bool = False
|
|
|
+ # #2702: one-shot re-request of the layer total. Armed at print start
|
|
|
+ # when the starting frame carried no `total_layer_num`, spent on the
|
|
|
+ # first layer advance that still has no denominator. Bambu firmware
|
|
|
+ # only re-sends *changed* fields, so a total we never received (or
|
|
|
+ # dropped) is only recoverable via a full pushall.
|
|
|
+ self._total_layers_refresh_armed: bool = False
|
|
|
+ # #2547 end-of-print telemetry probe state. `_armed` is cleared once the
|
|
|
+ # window has run for a print so a late FINISH re-send can't reopen it.
|
|
|
+ self._eop_probe_armed: bool = True
|
|
|
+ self._eop_probe_open: bool = False
|
|
|
+ self._eop_probe_frames: int = 0
|
|
|
+ self._eop_probe_last: dict = {}
|
|
|
self._last_valid_progress: float = 0.0 # Last non-zero progress (firmware resets on cancel)
|
|
|
self._last_valid_layer_num: int = 0 # Last non-zero layer (firmware resets on cancel)
|
|
|
# The subtask_id minted for the most recent start_print() command. The
|
|
|
@@ -713,6 +836,18 @@ class BambuMQTTClient:
|
|
|
# See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
|
|
|
self._has_a2l_am_unit: bool = False
|
|
|
|
|
|
+ # Why the last connection attempt was refused by the printer, or None
|
|
|
+ # when we have never seen a CONNACK failure since the last success.
|
|
|
+ # Without this a rejected access code was completely invisible: paho
|
|
|
+ # reports the follow-up disconnect as the generic "Unspecified error"
|
|
|
+ # and `_on_connect`'s failure branch used to log nothing at all, so a
|
|
|
+ # printer stuck in a reconnect loop looked identical whether it was
|
|
|
+ # powered off, on the wrong IP, or refusing our credentials (#2698).
|
|
|
+ # One of the CONNECT_ERROR_* slugs; the paired name is the paho reason
|
|
|
+ # string, kept for the log line only.
|
|
|
+ self.last_connect_error: str | None = None
|
|
|
+ self.last_connect_error_name: str | None = None
|
|
|
+
|
|
|
# Request topic subscription tracking
|
|
|
# Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
|
|
|
# topic by killing the TCP connection. We detect this and gracefully degrade.
|
|
|
@@ -730,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.
|
|
|
@@ -848,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 "
|
|
|
@@ -963,6 +1117,8 @@ class BambuMQTTClient:
|
|
|
def _on_connect(self, client, userdata, flags, rc, properties=None):
|
|
|
if rc == 0:
|
|
|
self.state.connected = True
|
|
|
+ self.last_connect_error = None
|
|
|
+ self.last_connect_error_name = None
|
|
|
self._stale_reconnecting = False # Clear stale-reconnect flag on successful connect
|
|
|
# A dropped-and-restored MQTT session means the presumed power-off was
|
|
|
# real (or at least that the printer restarted): there is nothing
|
|
|
@@ -1020,6 +1176,43 @@ class BambuMQTTClient:
|
|
|
self.on_state_change(self.state)
|
|
|
else:
|
|
|
self.state.connected = False
|
|
|
+ self._record_connect_refusal(rc)
|
|
|
+
|
|
|
+ def _record_connect_refusal(self, rc) -> None:
|
|
|
+ """Log and remember why the printer refused the MQTT connection.
|
|
|
+
|
|
|
+ The failure branch of ``_on_connect`` used to be a bare
|
|
|
+ ``connected = False``, which threw away the only signal that says
|
|
|
+ *why* a printer never comes online. The user-visible result was a
|
|
|
+ 30-second reconnect loop logging nothing but paho's generic
|
|
|
+ ``MQTT disconnected: rc=Unspecified error`` — indistinguishable from a
|
|
|
+ powered-off printer, so "my printer won't print" reports could not be
|
|
|
+ triaged without a round trip (#2698).
|
|
|
+
|
|
|
+ Never logs the access code itself; the code is the likely culprit but
|
|
|
+ printing it would put a credential in every support bundle.
|
|
|
+ """
|
|
|
+ code = getattr(rc, "value", rc)
|
|
|
+ name = rc.getName() if hasattr(rc, "getName") else str(rc)
|
|
|
+ self.last_connect_error_name = name
|
|
|
+ if isinstance(code, int) and code in _CONNACK_AUTH_REJECTED:
|
|
|
+ self.last_connect_error = CONNECT_ERROR_AUTH_REJECTED
|
|
|
+ logger.warning(
|
|
|
+ "[%s] MQTT connection refused by the printer: %s (code %s). The access code "
|
|
|
+ "or serial number is wrong — the access code changes every time LAN Only or "
|
|
|
+ "Developer Mode is toggled, so re-read it from the printer's screen.",
|
|
|
+ self.serial_number,
|
|
|
+ name,
|
|
|
+ code,
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ self.last_connect_error = CONNECT_ERROR_REFUSED
|
|
|
+ logger.warning(
|
|
|
+ "[%s] MQTT connection refused by the printer: %s (code %s).",
|
|
|
+ self.serial_number,
|
|
|
+ name,
|
|
|
+ code,
|
|
|
+ )
|
|
|
|
|
|
def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
|
|
|
"""Handle SUBACK responses to detect request topic subscription rejection."""
|
|
|
@@ -1076,7 +1269,21 @@ class BambuMQTTClient:
|
|
|
)
|
|
|
return
|
|
|
|
|
|
- logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
|
|
|
+ # Carry the last CONNACK refusal into the disconnect line. paho reports
|
|
|
+ # the drop that follows a refused CONNACK as "Unspecified error", so on
|
|
|
+ # its own this line says nothing useful about a printer that is looping
|
|
|
+ # on bad credentials — and this is the line that fills a support bundle
|
|
|
+ # (#2698).
|
|
|
+ if self.last_connect_error:
|
|
|
+ logger.warning(
|
|
|
+ "[%s] MQTT disconnected: rc=%s, flags=%s (last connection attempt was refused: %s)",
|
|
|
+ self.serial_number,
|
|
|
+ rc,
|
|
|
+ disconnect_flags,
|
|
|
+ self.last_connect_error_name,
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
|
|
|
|
|
|
# Detect if request topic subscription caused the disconnect.
|
|
|
# If we just subscribed and got disconnected before any SUBACK confirmation,
|
|
|
@@ -2702,10 +2909,108 @@ class BambuMQTTClient:
|
|
|
except Exception:
|
|
|
logger.exception("[%s] on_assignment_verified callback failed", self.serial_number)
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _probe_number(value, fallback: float | None = None) -> float | None:
|
|
|
+ """Coerce a telemetry field to a number, or return `fallback`.
|
|
|
+
|
|
|
+ Firmware is inconsistent about whether these arrive as ints or as
|
|
|
+ numeric strings, and the probe must never raise on a surprise type.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ return float(value)
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ return fallback
|
|
|
+
|
|
|
+ def _probe_end_of_print(self, data: dict) -> None:
|
|
|
+ """Log raw end-of-print telemetry for one print at DEBUG (#2547).
|
|
|
+
|
|
|
+ Opens on the first frame that looks like end-of-print (last object
|
|
|
+ layer reached, progress at 99+, or no remaining time), then logs each
|
|
|
+ frame in which any probed field changed, and closes on the transition
|
|
|
+ out of RUNNING. Armed once per print — see the module-level comment on
|
|
|
+ ``_END_OF_PRINT_PROBE_FIELDS`` for why this window is the one we can't
|
|
|
+ currently see into.
|
|
|
+
|
|
|
+ Read-only with respect to printer state: this is instrumentation, and
|
|
|
+ nothing downstream may come to depend on it.
|
|
|
+ """
|
|
|
+ if not logger.isEnabledFor(logging.DEBUG):
|
|
|
+ return
|
|
|
+ if not self._eop_probe_open and not (self._eop_probe_armed and self._was_running):
|
|
|
+ return
|
|
|
+
|
|
|
+ present = {k: data[k] for k in _END_OF_PRINT_PROBE_FIELDS if k in data}
|
|
|
+ if not present:
|
|
|
+ return
|
|
|
+
|
|
|
+ if not self._eop_probe_open:
|
|
|
+ # Open on any end-of-print signal. Read from the raw frame first so
|
|
|
+ # the frame that *carries* the signal is itself captured — state
|
|
|
+ # fields are only updated further down this same call.
|
|
|
+ layer = self._probe_number(data.get("layer_num"), self.state.layer_num) or 0
|
|
|
+ total = self._probe_number(data.get("total_layer_num"), self.state.total_layers) or 0
|
|
|
+ percent = self._probe_number(data.get("mc_percent"), self.state.progress) or 0
|
|
|
+ remaining = self._probe_number(data.get("mc_remaining_time"), self.state.remaining_time)
|
|
|
+ at_last_layer = total > 0 and layer >= total
|
|
|
+ # `remaining <= 0` is only meaningful once the print has actually
|
|
|
+ # progressed — it reads 0 during the pre-print calibration too.
|
|
|
+ out_of_time = remaining is not None and remaining <= 0 and percent > 0
|
|
|
+ if not (at_last_layer or percent >= 99 or out_of_time):
|
|
|
+ return
|
|
|
+ self._eop_probe_open = True
|
|
|
+ self._eop_probe_frames = 0
|
|
|
+ self._eop_probe_last = {}
|
|
|
+ logger.debug(
|
|
|
+ "[%s] EOP-PROBE open — layer=%s/%s percent=%s remaining=%s",
|
|
|
+ self.serial_number,
|
|
|
+ layer,
|
|
|
+ total,
|
|
|
+ percent,
|
|
|
+ remaining,
|
|
|
+ )
|
|
|
+
|
|
|
+ closing = str(data.get("gcode_state") or "") in _END_OF_PRINT_PROBE_CLOSING_STATES
|
|
|
+ changed = {k: v for k, v in present.items() if self._eop_probe_last.get(k, object()) != v}
|
|
|
+ self._eop_probe_last.update(present)
|
|
|
+
|
|
|
+ if self._eop_probe_frames >= _END_OF_PRINT_PROBE_MAX_FRAMES and not closing:
|
|
|
+ if self._eop_probe_frames == _END_OF_PRINT_PROBE_MAX_FRAMES:
|
|
|
+ self._eop_probe_frames += 1
|
|
|
+ logger.debug(
|
|
|
+ "[%s] EOP-PROBE frame budget (%s) reached — suppressing until FINISH",
|
|
|
+ self.serial_number,
|
|
|
+ _END_OF_PRINT_PROBE_MAX_FRAMES,
|
|
|
+ )
|
|
|
+ return
|
|
|
+
|
|
|
+ if changed or closing:
|
|
|
+ self._eop_probe_frames += 1
|
|
|
+ logger.debug(
|
|
|
+ "[%s] EOP-PROBE %s%s: %s",
|
|
|
+ self.serial_number,
|
|
|
+ self._eop_probe_frames,
|
|
|
+ " CLOSE" if closing else "",
|
|
|
+ # `changed` on a closing frame can be empty; fall back to the
|
|
|
+ # full picture so the last line is always self-contained.
|
|
|
+ changed if changed else present,
|
|
|
+ )
|
|
|
+
|
|
|
+ if closing:
|
|
|
+ self._eop_probe_open = False
|
|
|
+ self._eop_probe_armed = False
|
|
|
+ self._eop_probe_last = {}
|
|
|
+
|
|
|
def _update_state(self, data: dict):
|
|
|
"""Update printer state from message data."""
|
|
|
_previous_state = self.state.state
|
|
|
|
|
|
+ # #2547: instrumentation only — runs before any state mutation so the
|
|
|
+ # frame carrying an end-of-print signal is logged as it arrived.
|
|
|
+ try:
|
|
|
+ self._probe_end_of_print(data)
|
|
|
+ except Exception: # pragma: no cover - a probe must never break ingest
|
|
|
+ logger.debug("[%s] EOP-PROBE failed", self.serial_number, exc_info=True)
|
|
|
+
|
|
|
# Update state fields
|
|
|
if "gcode_state" in data:
|
|
|
self.state.state = data["gcode_state"]
|
|
|
@@ -2723,7 +3028,14 @@ class BambuMQTTClient:
|
|
|
# Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
|
|
|
if self.state.progress > 0:
|
|
|
self._last_valid_progress = self.state.progress
|
|
|
+ previous_progress = self.state.progress
|
|
|
self.state.progress = float(data["mc_percent"])
|
|
|
+ # #2547: strictly-increasing only. The firmware resets progress to 0
|
|
|
+ # on cancel and re-reports the same percent on most frames; neither
|
|
|
+ # is the print advancing, and both would make the frame bank grab a
|
|
|
+ # camera frame for nothing.
|
|
|
+ if self.state.progress > previous_progress and self._was_running and self.on_print_progress:
|
|
|
+ self.on_print_progress(int(self.state.progress))
|
|
|
if "mc_remaining_time" in data:
|
|
|
self.state.remaining_time = int(data["mc_remaining_time"])
|
|
|
if "mc_print_sub_stage" in data:
|
|
|
@@ -2734,8 +3046,49 @@ class BambuMQTTClient:
|
|
|
f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
|
|
|
)
|
|
|
self.state.mc_print_sub_stage = new_sub_stage
|
|
|
+ # Positive `total_layer_num` carried by *this* frame, or 0. Read up
|
|
|
+ # front because three places below consult it and they run in an order
|
|
|
+ # that is not the order they read most naturally in: the layer-advance
|
|
|
+ # refresh (#2702) must not fire on a frame that already answers it, the
|
|
|
+ # apply step must ignore firmware-reset 0s (#1771), and the new-print
|
|
|
+ # reset must not discard a total that belongs to the starting print.
|
|
|
+ total_from_this_frame = 0
|
|
|
+ if "total_layer_num" in data:
|
|
|
+ try:
|
|
|
+ total_from_this_frame = max(int(data["total_layer_num"] or 0), 0)
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ # Must not escape. `_on_message` catches only JSONDecodeError
|
|
|
+ # and paho is left at `suppress_exceptions = False`, so an
|
|
|
+ # exception raised here is re-raised on the network thread and
|
|
|
+ # takes the printer connection down over one unusable field.
|
|
|
+ # Treat it as "not reported": the refresh below then recovers
|
|
|
+ # the real total from a pushall.
|
|
|
+ logger.debug(
|
|
|
+ "[%s] ignoring unusable total_layer_num: %r",
|
|
|
+ self.serial_number,
|
|
|
+ data["total_layer_num"],
|
|
|
+ )
|
|
|
+
|
|
|
if "layer_num" in data:
|
|
|
- new_layer = int(data["layer_num"])
|
|
|
+ try:
|
|
|
+ new_layer = int(data["layer_num"])
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ # Contained for the same reason as `total_layer_num` above: an
|
|
|
+ # exception raised here escapes `_update_state` and paho
|
|
|
+ # re-raises it on the network thread. Losing this frame would
|
|
|
+ # also lose the print-start and completion detection further
|
|
|
+ # down, which is worse than losing a layer number.
|
|
|
+ #
|
|
|
+ # Held at the last known layer rather than substituted with 0:
|
|
|
+ # a fabricated 0 reads as the firmware's cancel reset, which
|
|
|
+ # would move `_last_valid_layer_num` and show layer 0 in the UI
|
|
|
+ # until the next good frame.
|
|
|
+ logger.debug(
|
|
|
+ "[%s] ignoring unusable layer_num: %r",
|
|
|
+ self.serial_number,
|
|
|
+ data["layer_num"],
|
|
|
+ )
|
|
|
+ new_layer = self.state.layer_num
|
|
|
old_layer = self.state.layer_num
|
|
|
# Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
|
|
|
if old_layer > 0:
|
|
|
@@ -2744,44 +3097,45 @@ class BambuMQTTClient:
|
|
|
# Trigger layer change callback if layer increased
|
|
|
if new_layer > old_layer and self.on_layer_change:
|
|
|
self.on_layer_change(new_layer)
|
|
|
- # #1867 last-layer finish-photo trigger. A1 Mini (and other
|
|
|
- # firmware variants) skips `stg_cur=22`, so the fallback fires
|
|
|
- # at gcode_state=FINISH — which runs AFTER user End G-code
|
|
|
- # (e.g. SwapMod plate-swap) and captures the wrong plate.
|
|
|
- # Firing on the layer_num→total_layer_num edge captures the
|
|
|
- # last object layer before any end G-code executes.
|
|
|
- total = self.state.total_layers or 0
|
|
|
+ # #2702: the print is demonstrably laying down layers but we still
|
|
|
+ # have no denominator, so the pushall requested at print start
|
|
|
+ # either went unanswered or raced the printer learning the total.
|
|
|
+ # Ask once more — by layer 1 the printer definitely knows it.
|
|
|
+ # One-shot: an unanswered pushall must not turn into a per-layer
|
|
|
+ # retry loop for the rest of the print.
|
|
|
if (
|
|
|
- total > 0
|
|
|
- and new_layer >= total
|
|
|
- and old_layer < total
|
|
|
- and self._was_running
|
|
|
- and not self._finish_photo_captured
|
|
|
- and self.on_finish_photo_moment
|
|
|
+ new_layer > old_layer
|
|
|
+ and self._total_layers_refresh_armed
|
|
|
+ and not self.state.total_layers
|
|
|
+ and not total_from_this_frame
|
|
|
):
|
|
|
- self._finish_photo_captured = True
|
|
|
- logger.info(
|
|
|
- f"[{self.serial_number}] FINISH PHOTO MOMENT (last-layer) — "
|
|
|
- f"layer={new_layer}/{total}, "
|
|
|
- f"timelapse_active={self._timelapse_during_print}"
|
|
|
- )
|
|
|
- self.on_finish_photo_moment(
|
|
|
- {
|
|
|
- "trigger": "last_layer",
|
|
|
- "filename": self._previous_gcode_file or self.state.gcode_file,
|
|
|
- "subtask_name": self.state.subtask_name,
|
|
|
- "timelapse_was_active": self._timelapse_during_print,
|
|
|
- }
|
|
|
+ self._total_layers_refresh_armed = False
|
|
|
+ logger.debug(
|
|
|
+ "[%s] layer %s with no total_layer_num — re-requesting full status",
|
|
|
+ self.serial_number,
|
|
|
+ new_layer,
|
|
|
)
|
|
|
- if "total_layer_num" in data:
|
|
|
- # Some firmware (P1S observed) resets `total_layer_num` to 0 at
|
|
|
- # print end — same shape as the `layer_num` reset guarded above.
|
|
|
- # Preserve the last known good value so the usage-tracker split
|
|
|
- # path (#1771) has a denominator that survives the reset frame.
|
|
|
- # Explicit reset to 0 happens on print start (`_handle_print_start`).
|
|
|
- new_total = int(data["total_layer_num"])
|
|
|
- if new_total > 0:
|
|
|
- self.state.total_layers = new_total
|
|
|
+ self._request_push_all()
|
|
|
+ # #2547: there is deliberately NO finish-photo trigger on the
|
|
|
+ # last-layer edge. `layer_num` reaching `total_layer_num` is the
|
|
|
+ # moment the printer *starts* the final layer, not the moment it
|
|
|
+ # finishes it — on the H2C capture that closed #2547 the edge
|
|
|
+ # arrived at 92% with `mc_remaining_time=2`, three minutes and a
|
|
|
+ # filament change before the print actually ended, so the photo
|
|
|
+ # showed the toolhead mid-print over the part. Worse, the trigger
|
|
|
+ # latched `_finish_photo_captured`, locking out both the stage-22
|
|
|
+ # and FINISH triggers below for the rest of the print.
|
|
|
+ #
|
|
|
+ # #1867 (End G-code ejects the plate before FINISH) is handled
|
|
|
+ # where it belongs instead: `on_finish_photo_moment` prefers the
|
|
|
+ # in-print frame bank when the dispatcher recorded that it injected
|
|
|
+ # End G-code into this print. See services/print_dispatch_context.
|
|
|
+ if total_from_this_frame:
|
|
|
+ # Firmware (P1S observed) resets `total_layer_num` to 0 at print
|
|
|
+ # end — same shape as the `layer_num` reset guarded above. Applying
|
|
|
+ # only positive values preserves the last known good denominator so
|
|
|
+ # the usage-tracker split path (#1771) survives the reset frame.
|
|
|
+ self.state.total_layers = total_from_this_frame
|
|
|
|
|
|
# Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
|
|
|
# Convert to 0-100 percentage for display
|
|
|
@@ -3215,6 +3569,83 @@ class BambuMQTTClient:
|
|
|
f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}"
|
|
|
)
|
|
|
self.state.airduct_mode = new_mode
|
|
|
+ # Parse individual airduct fan parts (new-protocol models: P2S/X2D/H2*).
|
|
|
+ # Raw part ids are bit-packed — decoded id = raw_id >> 4 (bits 4-11),
|
|
|
+ # mirroring Bambu Studio DevFan::ParseV3_0. Decoded ids follow the
|
|
|
+ # AIR_FUN enum: 1=part cooling, 2=right aux, 3=chamber/exhaust,
|
|
|
+ # 10=left aux (FAN_REMOTE_COOLING_1). The airduct `parts` list only
|
|
|
+ # contains the fans that physically exist, so it doubles as a
|
|
|
+ # presence signal for the two P2S/X2D add-on kits:
|
|
|
+ # - id 10 (left auxiliary part cooling fan) — reported ONLY here,
|
|
|
+ # never mirrored into a flat big_fanX_speed field.
|
|
|
+ # - id 3 (chamber exhaust fan) — its speed is mirrored into
|
|
|
+ # big_fan2_speed, but the part is only listed when the External
|
|
|
+ # Exhaust Fan kit (get_version module "eef") is installed.
|
|
|
+ # `state` is already a 0-100 percentage.
|
|
|
+ parts = airduct_data.get("parts")
|
|
|
+ if isinstance(parts, list):
|
|
|
+ speeds: dict[int, int] = {}
|
|
|
+ for part in parts:
|
|
|
+ if not isinstance(part, dict):
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ # Studio reads the id with get_flag_bits(id, 4, 8),
|
|
|
+ # so mask after shifting for the same reason `state`
|
|
|
+ # is masked below. Every id seen in the wild
|
|
|
+ # (16/32/48/160) decodes identically either way —
|
|
|
+ # this is consistency, not a live bug.
|
|
|
+ part_id = (int(part["id"]) >> 4) & 0xFF
|
|
|
+ # `state` is bit-packed like its sibling `range`
|
|
|
+ # (end << 16 | start), so take only the low 8 bits —
|
|
|
+ # the same decode Bambu Studio does with
|
|
|
+ # get_flag_bits(state, 0, 8). Without the mask a
|
|
|
+ # packed value would clamp to 100 instead of
|
|
|
+ # decoding to the real percentage.
|
|
|
+ part_state = int(part["state"]) & 0xFF
|
|
|
+ except (KeyError, ValueError, TypeError):
|
|
|
+ continue
|
|
|
+ # Ids seen across the support-package archive:
|
|
|
+ # 1 part cooling, 2 aux, 3 chamber/exhaust,
|
|
|
+ # 6 (H2 series, unmapped), 10 left aux.
|
|
|
+ speeds[part_id] = max(0, min(100, part_state))
|
|
|
+
|
|
|
+ # Absence in this list is what tells us a kit is NOT fitted,
|
|
|
+ # so it may only be trusted when the list is a full
|
|
|
+ # inventory rather than a diff frame. `device.airduct` is
|
|
|
+ # pushed field by field — the `modeCur` handler above exists
|
|
|
+ # for exactly that reason — and a truncated `parts` read as
|
|
|
+ # gospel would retract both accessory badges mid-print and
|
|
|
+ # start rejecting `aux2` on a printer that has the fan.
|
|
|
+ #
|
|
|
+ # Every airduct layout in the support-package archive
|
|
|
+ # (P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 /
|
|
|
+ # H2C,H2D,H2S 1,2,3,6 — 37 of 37 bundles) contains both the
|
|
|
+ # part cooling fan and the aux fan, neither of which is
|
|
|
+ # optional on any machine that reports an airduct at all.
|
|
|
+ # A list carrying both is therefore a complete inventory; a
|
|
|
+ # list missing either is a partial frame, and we take its
|
|
|
+ # speeds without touching presence.
|
|
|
+ is_full_inventory = 1 in speeds and 2 in speeds
|
|
|
+
|
|
|
+ left_aux_speed = speeds.get(10)
|
|
|
+ if left_aux_speed is None and not is_full_inventory:
|
|
|
+ # Partial frame that didn't mention the left aux fan —
|
|
|
+ # keep whatever we already knew about it.
|
|
|
+ left_aux_speed = self.state.left_aux_fan_speed
|
|
|
+ if left_aux_speed != self.state.left_aux_fan_speed:
|
|
|
+ logger.debug(
|
|
|
+ f"[{self.serial_number}] left_aux_fan_speed changed: "
|
|
|
+ f"{self.state.left_aux_fan_speed} -> {left_aux_speed}"
|
|
|
+ )
|
|
|
+ # A FULL parts list without id 10 means the left aux fan is
|
|
|
+ # not installed — report None so the UI can hide the widget.
|
|
|
+ self.state.left_aux_fan_speed = left_aux_speed
|
|
|
+ # id 3 present == chamber exhaust fan installed (base P2S
|
|
|
+ # omits it). Only ever retracted on a full inventory.
|
|
|
+ if 3 in speeds:
|
|
|
+ self.state.exhaust_fan_present = True
|
|
|
+ elif is_full_inventory:
|
|
|
+ self.state.exhaust_fan_present = False
|
|
|
# Parse chamber temp - may be encoded as (target*65536+current) when > 500
|
|
|
# Check if we recently set the target locally (within 5 seconds)
|
|
|
local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0)
|
|
|
@@ -3348,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):
|
|
|
@@ -3383,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("_", ""))
|
|
|
@@ -3397,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:
|
|
|
@@ -3840,16 +4275,39 @@ class BambuMQTTClient:
|
|
|
# Reset layer tracking for new print (needed for layer-based timelapse)
|
|
|
self.state.layer_num = 0
|
|
|
# Reset total_layers so the previous print's value can't bleed into
|
|
|
- # this print's usage-tracker split before the new push_status arrives
|
|
|
- # with the slicer's total (#1771 follow-on to the preservation guard
|
|
|
- # above at line ~2135 — the guard now ignores firmware-reset 0s, so
|
|
|
- # the explicit reset has to happen here instead).
|
|
|
- self.state.total_layers = 0
|
|
|
+ # this print's usage-tracker split (#1771 follow-on to the
|
|
|
+ # preservation guard at the `total_layer_num` parse above — that
|
|
|
+ # guard ignores firmware-reset 0s, so the explicit reset has to
|
|
|
+ # happen here instead).
|
|
|
+ #
|
|
|
+ # #2702: reset to *this frame's* total, not to 0. The frame that
|
|
|
+ # trips the new-print detection can carry the new print's
|
|
|
+ # `total_layer_num` as well — the parse above has already applied
|
|
|
+ # it, and zeroing unconditionally threw it away. That looked
|
|
|
+ # harmless but is not recoverable: Bambu firmware sends only
|
|
|
+ # changed fields, so the printer never offers the total again, and
|
|
|
+ # the print runs to completion at `n/0` in the UI, in
|
|
|
+ # `{total_layers}` notifications, and as the usage-split
|
|
|
+ # denominator. The value only reappears on the next full pushall
|
|
|
+ # (reconnect / Force Refresh), which is why the symptom looked
|
|
|
+ # random and why a *stable* connection made it worse.
|
|
|
+ self.state.total_layers = total_from_this_frame
|
|
|
+ # If the starting frame brought no total, ask for one. Costs one
|
|
|
+ # MQTT message per print and covers the ordering where the printer
|
|
|
+ # published the total a frame or two before the state flip.
|
|
|
+ self._total_layers_refresh_armed = not total_from_this_frame
|
|
|
+ if self._total_layers_refresh_armed:
|
|
|
+ self._request_push_all()
|
|
|
# Reset completion tracking for new print
|
|
|
self._was_running = True
|
|
|
self._completion_triggered = False
|
|
|
# #1721: rearm the end-of-print finish-photo trigger for the new print
|
|
|
self._finish_photo_captured = False
|
|
|
+ # #2547: rearm the end-of-print telemetry probe for the new print
|
|
|
+ self._eop_probe_armed = True
|
|
|
+ self._eop_probe_open = False
|
|
|
+ self._eop_probe_frames = 0
|
|
|
+ self._eop_probe_last = {}
|
|
|
# Reset last valid progress/layer for usage tracking
|
|
|
self._last_valid_progress = 0.0
|
|
|
self._last_valid_layer_num = 0
|
|
|
@@ -4074,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
|
|
|
@@ -4087,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)
|
|
|
@@ -5463,13 +5986,16 @@ class BambuMQTTClient:
|
|
|
"""Set fan speed.
|
|
|
|
|
|
Args:
|
|
|
- fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber)
|
|
|
+ fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber, 10=left auxiliary).
|
|
|
+ Index 10 is the optional left auxiliary part cooling fan on P2S/X2D
|
|
|
+ (airduct part id 10); Bambu's official machine profiles drive it with
|
|
|
+ "M106 P10" in start/layer-change gcode.
|
|
|
speed: Speed 0-255 (0=off, 255=full)
|
|
|
|
|
|
Returns:
|
|
|
True if command was sent, False otherwise
|
|
|
"""
|
|
|
- if fan not in (1, 2, 3):
|
|
|
+ if fan not in (1, 2, 3, 10):
|
|
|
logger.warning("[%s] Invalid fan index: %s", self.serial_number, fan)
|
|
|
return False
|
|
|
|
|
|
@@ -5488,6 +6014,10 @@ class BambuMQTTClient:
|
|
|
"""Set chamber fan speed (0-255)."""
|
|
|
return self.set_fan_speed(3, speed)
|
|
|
|
|
|
+ def set_left_aux_fan(self, speed: int) -> bool:
|
|
|
+ """Set left auxiliary part cooling fan speed (0-255). P2S/X2D accessory."""
|
|
|
+ return self.set_fan_speed(10, speed)
|
|
|
+
|
|
|
def set_airduct_mode(self, mode: str) -> bool:
|
|
|
"""Set air conditioning mode (cooling or heating).
|
|
|
|