|
@@ -818,6 +818,11 @@ class BambuMQTTClient:
|
|
|
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
|
|
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
|
|
|
self._sequence_id: int = 0
|
|
self._sequence_id: int = 0
|
|
|
self._pending_kprofile_requests: dict[str, dict] = {}
|
|
self._pending_kprofile_requests: dict[str, dict] = {}
|
|
|
|
|
+ # Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
|
|
|
|
|
+ # keyed by the sequence_id we sent. The printer echoes it back, measured
|
|
|
|
|
+ # on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
|
|
|
|
|
+ # by await_cali_ack.
|
|
|
|
|
+ self._pending_cali_acks: dict[str, dict | None] = {}
|
|
|
|
|
|
|
|
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
|
|
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
|
|
|
# Key: module_name, Value: timestamp when command was sent
|
|
# Key: module_name, Value: timestamp when command was sent
|
|
@@ -1618,7 +1623,24 @@ class BambuMQTTClient:
|
|
|
if "command" in print_data:
|
|
if "command" in print_data:
|
|
|
cmd = print_data.get("command")
|
|
cmd = print_data.get("command")
|
|
|
logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
|
|
logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
|
|
|
- if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
|
|
|
|
|
|
|
+ if cmd in ("extrusion_cali_set", "extrusion_cali_del"):
|
|
|
|
|
+ # INFO, not debug: this is the printer's verdict on a write
|
|
|
|
|
+ # the user just made, and it was invisible in support
|
|
|
|
|
+ # bundles for as long as it sat at DEBUG (#2718). Same
|
|
|
|
|
+ # reasoning as ams_filament_drying below.
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] %s response: result=%s reason=%s seq=%s",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ cmd,
|
|
|
|
|
+ print_data.get("result"),
|
|
|
|
|
+ print_data.get("reason", ""),
|
|
|
|
|
+ print_data.get("sequence_id"),
|
|
|
|
|
+ )
|
|
|
|
|
+ logger.debug("[%s] %s full response: %s", self.serial_number, cmd, print_data)
|
|
|
|
|
+ ack_seq = str(print_data.get("sequence_id", ""))
|
|
|
|
|
+ if ack_seq in self._pending_cali_acks:
|
|
|
|
|
+ self._pending_cali_acks[ack_seq] = print_data
|
|
|
|
|
+ elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
|
|
|
logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
|
|
logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
|
|
|
# AMS drying responses are rare (user-initiated only) and the
|
|
# AMS drying responses are rare (user-initiated only) and the
|
|
|
# full payload — including `result` and any `reason` code —
|
|
# full payload — including `result` and any `reason` code —
|
|
@@ -5602,6 +5624,51 @@ class BambuMQTTClient:
|
|
|
logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
|
|
logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
|
|
|
return []
|
|
return []
|
|
|
|
|
|
|
|
|
|
+ def _publish_cali_write(self, command: dict, seq_id: str) -> bool:
|
|
|
|
|
+ """Publish a K-profile write and arm its ack slot.
|
|
|
|
|
+
|
|
|
|
|
+ Registration happens before the publish because the printer answers in
|
|
|
|
|
+ well under a second — measured at 70-150ms — which is comfortably
|
|
|
|
|
+ before an async caller gets back to awaiting.
|
|
|
|
|
+ """
|
|
|
|
|
+ self._pending_cali_acks[seq_id] = None
|
|
|
|
|
+ try:
|
|
|
|
|
+ self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ self._pending_cali_acks.pop(seq_id, None)
|
|
|
|
|
+ raise
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ async def await_cali_ack(self, seq_id: str, timeout: float = 6.0) -> tuple[bool, str]:
|
|
|
|
|
+ """Wait for the printer's verdict on a K-profile write.
|
|
|
|
|
+
|
|
|
|
|
+ Returns ``(ok, detail)``. ``ok`` is False only when the printer
|
|
|
|
|
+ explicitly said ``result: "fail"`` — a timeout returns True with a
|
|
|
|
|
+ detail string, because "no answer" is not evidence of rejection and
|
|
|
|
|
+ older firmware may not answer at all. Callers that need certainty read
|
|
|
|
|
+ the calibration table back.
|
|
|
|
|
+
|
|
|
|
|
+ Polled rather than event-driven on purpose: the ack is filled in by the
|
|
|
|
|
+ MQTT callback thread, and polling a dict costs one lookup every 50ms
|
|
|
|
|
+ for at most a few hundred milliseconds, against the cross-thread
|
|
|
|
|
+ event plumbing it would otherwise take.
|
|
|
|
|
+ """
|
|
|
|
|
+ deadline = time.monotonic() + timeout
|
|
|
|
|
+ try:
|
|
|
|
|
+ while time.monotonic() < deadline:
|
|
|
|
|
+ ack = self._pending_cali_acks.get(seq_id)
|
|
|
|
|
+ if ack is not None:
|
|
|
|
|
+ result = str(ack.get("result", "")).lower()
|
|
|
|
|
+ reason = str(ack.get("reason", "") or "")
|
|
|
|
|
+ if result == "fail":
|
|
|
|
|
+ return (False, reason or "printer reported failure")
|
|
|
|
|
+ return (True, reason)
|
|
|
|
|
+ await asyncio.sleep(0.05)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self._pending_cali_acks.pop(seq_id, None)
|
|
|
|
|
+ logger.warning("[%s] No ack for K-profile write seq=%s within %.1fs", self.serial_number, seq_id, timeout)
|
|
|
|
|
+ return (True, "no acknowledgement from printer")
|
|
|
|
|
+
|
|
|
def set_kprofile(
|
|
def set_kprofile(
|
|
|
self,
|
|
self,
|
|
|
filament_id: str,
|
|
filament_id: str,
|
|
@@ -5613,7 +5680,7 @@ class BambuMQTTClient:
|
|
|
setting_id: str | None = None,
|
|
setting_id: str | None = None,
|
|
|
slot_id: int = 0,
|
|
slot_id: int = 0,
|
|
|
cali_idx: int | None = None,
|
|
cali_idx: int | None = None,
|
|
|
- ) -> bool:
|
|
|
|
|
|
|
+ ) -> str | None:
|
|
|
"""Set/update a K-profile on the printer.
|
|
"""Set/update a K-profile on the printer.
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
@@ -5628,13 +5695,16 @@ class BambuMQTTClient:
|
|
|
cali_idx: For edits, the existing slot being edited (enables in-place edit)
|
|
cali_idx: For edits, the existing slot being edited (enables in-place edit)
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
- True if command was sent, False otherwise
|
|
|
|
|
|
|
+ The sequence_id the command was sent under, so the caller can
|
|
|
|
|
+ await the printer's verdict via await_cali_ack. None if the
|
|
|
|
|
+ command could not be sent.
|
|
|
"""
|
|
"""
|
|
|
if not self._client or not self.state.connected:
|
|
if not self._client or not self.state.connected:
|
|
|
logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
|
|
logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
|
|
|
- return False
|
|
|
|
|
|
|
+ return None
|
|
|
|
|
|
|
|
self._sequence_id += 1
|
|
self._sequence_id += 1
|
|
|
|
|
+ seq_id = str(self._sequence_id)
|
|
|
|
|
|
|
|
# Build the filament entry - printer uses cali_idx for profile identification
|
|
# Build the filament entry - printer uses cali_idx for profile identification
|
|
|
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
|
|
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
|
|
@@ -5662,7 +5732,13 @@ class BambuMQTTClient:
|
|
|
"nozzle_diameter": nozzle_diameter,
|
|
"nozzle_diameter": nozzle_diameter,
|
|
|
"nozzle_id": nozzle_id,
|
|
"nozzle_id": nozzle_id,
|
|
|
"setting_id": setting_id if setting_id else "",
|
|
"setting_id": setting_id if setting_id else "",
|
|
|
- "tray_id": -1,
|
|
|
|
|
|
|
+ # 0, not -1. Single-nozzle firmware validates this field and
|
|
|
|
|
+ # answers `result: "fail", reason: "invalid tray_id"` to -1 — while
|
|
|
|
|
+ # applying the write anyway, so the rejection looked like noise.
|
|
|
|
|
+ # Measured on an X1C: flipping only this value turns the ack into
|
|
|
|
|
+ # `success` (#2718). BambuStudio always sends a real tray_id and
|
|
|
|
|
+ # defaults it to 0 for a manually entered profile.
|
|
|
|
|
+ "tray_id": 0,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
command = {
|
|
command = {
|
|
@@ -5670,7 +5746,7 @@ class BambuMQTTClient:
|
|
|
"command": "extrusion_cali_set",
|
|
"command": "extrusion_cali_set",
|
|
|
"filaments": [filament_entry],
|
|
"filaments": [filament_entry],
|
|
|
"nozzle_diameter": nozzle_diameter,
|
|
"nozzle_diameter": nozzle_diameter,
|
|
|
- "sequence_id": str(self._sequence_id),
|
|
|
|
|
|
|
+ "sequence_id": seq_id,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -5679,14 +5755,14 @@ class BambuMQTTClient:
|
|
|
f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
|
|
f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
|
|
|
)
|
|
)
|
|
|
logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
|
|
logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
|
|
|
- self._client.publish(self.topic_publish, command_json, qos=1)
|
|
|
|
|
- return True
|
|
|
|
|
|
|
+ self._publish_cali_write(command, seq_id)
|
|
|
|
|
+ return seq_id
|
|
|
|
|
|
|
|
def set_kprofiles_batch(
|
|
def set_kprofiles_batch(
|
|
|
self,
|
|
self,
|
|
|
profiles: list[dict],
|
|
profiles: list[dict],
|
|
|
nozzle_diameter: str = "0.4",
|
|
nozzle_diameter: str = "0.4",
|
|
|
- ) -> bool:
|
|
|
|
|
|
|
+ ) -> str | None:
|
|
|
"""Set multiple K-profiles in a single command (for dual-nozzle).
|
|
"""Set multiple K-profiles in a single command (for dual-nozzle).
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
@@ -5695,15 +5771,17 @@ class BambuMQTTClient:
|
|
|
nozzle_diameter: Common nozzle diameter for all profiles
|
|
nozzle_diameter: Common nozzle diameter for all profiles
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
- True if command was sent, False otherwise
|
|
|
|
|
|
|
+ The sequence_id the command was sent under (see set_kprofile),
|
|
|
|
|
+ or None if it could not be sent.
|
|
|
"""
|
|
"""
|
|
|
if not self._client or not self.state.connected:
|
|
if not self._client or not self.state.connected:
|
|
|
logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
|
|
logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
|
|
|
- return False
|
|
|
|
|
|
|
+ return None
|
|
|
|
|
|
|
|
import random
|
|
import random
|
|
|
|
|
|
|
|
self._sequence_id += 1
|
|
self._sequence_id += 1
|
|
|
|
|
+ seq_id = str(self._sequence_id)
|
|
|
|
|
|
|
|
filament_entries = []
|
|
filament_entries = []
|
|
|
for p in profiles:
|
|
for p in profiles:
|
|
@@ -5731,7 +5809,9 @@ class BambuMQTTClient:
|
|
|
"nozzle_diameter": nozzle_diameter,
|
|
"nozzle_diameter": nozzle_diameter,
|
|
|
"nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
|
|
"nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
|
|
|
"setting_id": setting_id if setting_id else "",
|
|
"setting_id": setting_id if setting_id else "",
|
|
|
- "tray_id": -1,
|
|
|
|
|
|
|
+ # See set_kprofile: -1 is rejected as "invalid tray_id" by
|
|
|
|
|
+ # single-nozzle firmware even though the write lands (#2718).
|
|
|
|
|
+ "tray_id": 0,
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -5740,15 +5820,15 @@ class BambuMQTTClient:
|
|
|
"command": "extrusion_cali_set",
|
|
"command": "extrusion_cali_set",
|
|
|
"filaments": filament_entries,
|
|
"filaments": filament_entries,
|
|
|
"nozzle_diameter": nozzle_diameter,
|
|
"nozzle_diameter": nozzle_diameter,
|
|
|
- "sequence_id": str(self._sequence_id),
|
|
|
|
|
|
|
+ "sequence_id": seq_id,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
command_json = json.dumps(command)
|
|
command_json = json.dumps(command)
|
|
|
logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
|
|
logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
|
|
|
logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
|
|
logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
|
|
|
- self._client.publish(self.topic_publish, command_json, qos=1)
|
|
|
|
|
- return True
|
|
|
|
|
|
|
+ self._publish_cali_write(command, seq_id)
|
|
|
|
|
+ return seq_id
|
|
|
|
|
|
|
|
def delete_kprofile(
|
|
def delete_kprofile(
|
|
|
self,
|
|
self,
|
|
@@ -5758,7 +5838,7 @@ class BambuMQTTClient:
|
|
|
nozzle_diameter: str = "0.4",
|
|
nozzle_diameter: str = "0.4",
|
|
|
extruder_id: int = 0,
|
|
extruder_id: int = 0,
|
|
|
setting_id: str | None = None,
|
|
setting_id: str | None = None,
|
|
|
- ) -> bool:
|
|
|
|
|
|
|
+ ) -> str | None:
|
|
|
"""Delete a K-profile from the printer.
|
|
"""Delete a K-profile from the printer.
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
@@ -5770,13 +5850,15 @@ class BambuMQTTClient:
|
|
|
setting_id: Unique setting identifier (for X1C series)
|
|
setting_id: Unique setting identifier (for X1C series)
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
- True if command was sent, False otherwise
|
|
|
|
|
|
|
+ The sequence_id the command was sent under (see set_kprofile),
|
|
|
|
|
+ or None if it could not be sent.
|
|
|
"""
|
|
"""
|
|
|
if not self._client or not self.state.connected:
|
|
if not self._client or not self.state.connected:
|
|
|
logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
|
|
logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
|
|
|
- return False
|
|
|
|
|
|
|
+ return None
|
|
|
|
|
|
|
|
self._sequence_id += 1
|
|
self._sequence_id += 1
|
|
|
|
|
+ seq_id = str(self._sequence_id)
|
|
|
|
|
|
|
|
# Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
|
|
# Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
|
|
|
# single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
|
|
# single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
|
|
@@ -5792,7 +5874,7 @@ class BambuMQTTClient:
|
|
|
command = {
|
|
command = {
|
|
|
"print": {
|
|
"print": {
|
|
|
"command": "extrusion_cali_del",
|
|
"command": "extrusion_cali_del",
|
|
|
- "sequence_id": str(self._sequence_id),
|
|
|
|
|
|
|
+ "sequence_id": seq_id,
|
|
|
"extruder_id": extruder_id,
|
|
"extruder_id": extruder_id,
|
|
|
"nozzle_id": nozzle_id,
|
|
"nozzle_id": nozzle_id,
|
|
|
"filament_id": filament_id,
|
|
"filament_id": filament_id,
|
|
@@ -5806,7 +5888,7 @@ class BambuMQTTClient:
|
|
|
command = {
|
|
command = {
|
|
|
"print": {
|
|
"print": {
|
|
|
"command": "extrusion_cali_del",
|
|
"command": "extrusion_cali_del",
|
|
|
- "sequence_id": str(self._sequence_id),
|
|
|
|
|
|
|
+ "sequence_id": seq_id,
|
|
|
"filament_id": filament_id,
|
|
"filament_id": filament_id,
|
|
|
"cali_idx": cali_idx,
|
|
"cali_idx": cali_idx,
|
|
|
"setting_id": setting_id if setting_id else "",
|
|
"setting_id": setting_id if setting_id else "",
|
|
@@ -5821,9 +5903,9 @@ class BambuMQTTClient:
|
|
|
f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
|
|
f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
|
|
|
)
|
|
)
|
|
|
logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
|
|
logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
|
|
|
- # Use QoS 1 for reliable delivery (at least once)
|
|
|
|
|
- self._client.publish(self.topic_publish, command_json, qos=1)
|
|
|
|
|
- return True
|
|
|
|
|
|
|
+ # QoS 1 for reliable delivery (at least once)
|
|
|
|
|
+ self._publish_cali_write(command, seq_id)
|
|
|
|
|
+ return seq_id
|
|
|
|
|
|
|
|
# =========================================================================
|
|
# =========================================================================
|
|
|
# Printer Control Commands
|
|
# Printer Control Commands
|
|
@@ -6666,6 +6748,11 @@ class BambuMQTTClient:
|
|
|
logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
|
|
logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
|
|
+ # Was reusing the previous command's id — harmless while nothing
|
|
|
|
|
+ # correlated on it, but the printer echoes sequence_id back and the
|
|
|
|
|
+ # K-profile write path now matches acks by it (#2718).
|
|
|
|
|
+ self._sequence_id += 1
|
|
|
|
|
+
|
|
|
nozzle_id = f"HS00-{nozzle_diameter}"
|
|
nozzle_id = f"HS00-{nozzle_diameter}"
|
|
|
|
|
|
|
|
# A2L AMS-Lite: a normalised global tray (24-27) must go out as the
|
|
# A2L AMS-Lite: a normalised global tray (24-27) must go out as the
|