bambu_mqtt.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. import json
  2. import ssl
  3. import asyncio
  4. import logging
  5. import time
  6. from collections import deque
  7. from datetime import datetime
  8. from typing import Callable
  9. from dataclasses import dataclass, field
  10. import paho.mqtt.client as mqtt
  11. logger = logging.getLogger(__name__)
  12. @dataclass
  13. class MQTTLogEntry:
  14. """Log entry for MQTT message debugging."""
  15. timestamp: str
  16. topic: str
  17. direction: str # "in" or "out"
  18. payload: dict
  19. @dataclass
  20. class HMSError:
  21. """Health Management System error from printer."""
  22. code: str
  23. module: int
  24. severity: int # 1=fatal, 2=serious, 3=common, 4=info
  25. message: str = ""
  26. @dataclass
  27. class KProfile:
  28. """Pressure advance (K) calibration profile from printer."""
  29. slot_id: int
  30. extruder_id: int
  31. nozzle_id: str
  32. nozzle_diameter: str
  33. filament_id: str
  34. name: str
  35. k_value: str
  36. n_coef: str = "0.000000"
  37. ams_id: int = 0
  38. tray_id: int = -1
  39. setting_id: str | None = None
  40. @dataclass
  41. class PrinterState:
  42. connected: bool = False
  43. state: str = "unknown"
  44. current_print: str | None = None
  45. subtask_name: str | None = None
  46. progress: float = 0.0
  47. remaining_time: int = 0
  48. layer_num: int = 0
  49. total_layers: int = 0
  50. temperatures: dict = field(default_factory=dict)
  51. raw_data: dict = field(default_factory=dict)
  52. gcode_file: str | None = None
  53. subtask_id: str | None = None
  54. hms_errors: list = field(default_factory=list) # List of HMSError
  55. kprofiles: list = field(default_factory=list) # List of KProfile
  56. class BambuMQTTClient:
  57. """MQTT client for Bambu Lab printer communication."""
  58. MQTT_PORT = 8883
  59. def __init__(
  60. self,
  61. ip_address: str,
  62. serial_number: str,
  63. access_code: str,
  64. on_state_change: Callable[[PrinterState], None] | None = None,
  65. on_print_start: Callable[[dict], None] | None = None,
  66. on_print_complete: Callable[[dict], None] | None = None,
  67. ):
  68. self.ip_address = ip_address
  69. self.serial_number = serial_number
  70. self.access_code = access_code
  71. self.on_state_change = on_state_change
  72. self.on_print_start = on_print_start
  73. self.on_print_complete = on_print_complete
  74. self.state = PrinterState()
  75. self._client: mqtt.Client | None = None
  76. self._loop: asyncio.AbstractEventLoop | None = None
  77. self._previous_gcode_state: str | None = None
  78. self._previous_gcode_file: str | None = None
  79. self._was_running: bool = False # Track if we've seen RUNNING state for current print
  80. self._completion_triggered: bool = False # Prevent duplicate completion triggers
  81. self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
  82. self._logging_enabled: bool = False
  83. self._last_message_time: float = 0.0 # Track when we last received a message
  84. # K-profile command tracking
  85. self._sequence_id: int = 0
  86. self._pending_kprofile_response: asyncio.Event | None = None
  87. self._kprofile_response_data: list | None = None
  88. @property
  89. def topic_subscribe(self) -> str:
  90. return f"device/{self.serial_number}/report"
  91. @property
  92. def topic_publish(self) -> str:
  93. return f"device/{self.serial_number}/request"
  94. def _on_connect(self, client, userdata, flags, rc, properties=None):
  95. if rc == 0:
  96. self.state.connected = True
  97. client.subscribe(self.topic_subscribe)
  98. # Request full status update
  99. self._request_push_all()
  100. # Immediately broadcast connection state change
  101. if self.on_state_change:
  102. self.on_state_change(self.state)
  103. else:
  104. self.state.connected = False
  105. def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
  106. # Ignore spurious disconnect callbacks if we've received a message recently
  107. # Paho-mqtt sometimes fires disconnect callbacks while the connection is still active
  108. time_since_last_message = time.time() - self._last_message_time
  109. if time_since_last_message < 30.0 and self._last_message_time > 0:
  110. logger.debug(
  111. f"[{self.serial_number}] Ignoring spurious disconnect (last message {time_since_last_message:.1f}s ago)"
  112. )
  113. return
  114. logger.warning(f"[{self.serial_number}] MQTT disconnected: rc={rc}, flags={disconnect_flags}")
  115. self.state.connected = False
  116. if self.on_state_change:
  117. self.on_state_change(self.state)
  118. def _on_message(self, client, userdata, msg):
  119. try:
  120. payload = json.loads(msg.payload.decode())
  121. # Track last message time - receiving a message proves we're connected
  122. self._last_message_time = time.time()
  123. self.state.connected = True
  124. # Log message if logging is enabled
  125. if self._logging_enabled:
  126. self._message_log.append(MQTTLogEntry(
  127. timestamp=datetime.now().isoformat(),
  128. topic=msg.topic,
  129. direction="in",
  130. payload=payload,
  131. ))
  132. self._process_message(payload)
  133. except json.JSONDecodeError:
  134. pass
  135. def _process_message(self, payload: dict):
  136. """Process incoming MQTT message from printer."""
  137. if "print" in payload:
  138. print_data = payload["print"]
  139. # Log when we see gcode_state changes
  140. if "gcode_state" in print_data:
  141. logger.info(
  142. f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
  143. f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
  144. )
  145. # Check for K-profile response (extrusion_cali)
  146. if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
  147. self._handle_kprofile_response(print_data)
  148. self._update_state(print_data)
  149. def _update_state(self, data: dict):
  150. """Update printer state from message data."""
  151. previous_state = self.state.state
  152. # Update state fields
  153. if "gcode_state" in data:
  154. self.state.state = data["gcode_state"]
  155. if "gcode_file" in data:
  156. self.state.gcode_file = data["gcode_file"]
  157. self.state.current_print = data["gcode_file"]
  158. if "subtask_name" in data:
  159. self.state.subtask_name = data["subtask_name"]
  160. # Prefer subtask_name as current_print if available
  161. if data["subtask_name"]:
  162. self.state.current_print = data["subtask_name"]
  163. if "subtask_id" in data:
  164. self.state.subtask_id = data["subtask_id"]
  165. if "mc_percent" in data:
  166. self.state.progress = float(data["mc_percent"])
  167. if "mc_remaining_time" in data:
  168. self.state.remaining_time = int(data["mc_remaining_time"])
  169. if "layer_num" in data:
  170. self.state.layer_num = int(data["layer_num"])
  171. if "total_layer_num" in data:
  172. self.state.total_layers = int(data["total_layer_num"])
  173. # Temperature data
  174. temps = {}
  175. # Log all temperature-related fields for debugging (only when we have temp data)
  176. temp_fields = {k: v for k, v in data.items() if 'temp' in k.lower() or 'nozzle' in k.lower()}
  177. if temp_fields and not hasattr(self, '_temp_fields_logged'):
  178. logger.info(f"[{self.serial_number}] Temperature fields in MQTT data: {temp_fields}")
  179. self._temp_fields_logged = True
  180. if "bed_temper" in data:
  181. temps["bed"] = float(data["bed_temper"])
  182. if "bed_target_temper" in data:
  183. temps["bed_target"] = float(data["bed_target_temper"])
  184. if "nozzle_temper" in data:
  185. temps["nozzle"] = float(data["nozzle_temper"])
  186. if "nozzle_target_temper" in data:
  187. temps["nozzle_target"] = float(data["nozzle_target_temper"])
  188. # Second nozzle for dual-extruder printers (H2 series)
  189. # Try multiple possible field names used by different firmware versions
  190. if "nozzle_temper_2" in data:
  191. temps["nozzle_2"] = float(data["nozzle_temper_2"])
  192. elif "right_nozzle_temper" in data:
  193. temps["nozzle_2"] = float(data["right_nozzle_temper"])
  194. if "nozzle_target_temper_2" in data:
  195. temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
  196. elif "right_nozzle_target_temper" in data:
  197. temps["nozzle_2_target"] = float(data["right_nozzle_target_temper"])
  198. # Also check for left nozzle as primary (some H2 models)
  199. if "left_nozzle_temper" in data and "nozzle" not in temps:
  200. temps["nozzle"] = float(data["left_nozzle_temper"])
  201. if "left_nozzle_target_temper" in data and "nozzle_target" not in temps:
  202. temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
  203. if "chamber_temper" in data:
  204. temps["chamber"] = float(data["chamber_temper"])
  205. if temps:
  206. self.state.temperatures = temps
  207. # Parse HMS (Health Management System) errors
  208. if "hms" in data:
  209. hms_list = data["hms"]
  210. self.state.hms_errors = []
  211. if isinstance(hms_list, list):
  212. for hms in hms_list:
  213. if isinstance(hms, dict):
  214. # HMS format: {"attr": code, "code": full_code}
  215. # The code is a hex string, severity is in bits
  216. code = hms.get("code", hms.get("attr", "0"))
  217. if isinstance(code, int):
  218. code = hex(code)
  219. # Parse severity from code (typically last 4 bits indicate level)
  220. try:
  221. code_int = int(str(code).replace("0x", ""), 16) if code else 0
  222. severity = (code_int >> 16) & 0xF # Extract severity bits
  223. module = (code_int >> 24) & 0xFF # Extract module bits
  224. except (ValueError, TypeError):
  225. severity = 3
  226. module = 0
  227. self.state.hms_errors.append(HMSError(
  228. code=str(code),
  229. module=module,
  230. severity=severity if severity > 0 else 3,
  231. ))
  232. self.state.raw_data = data
  233. # Log state transitions for debugging
  234. if "gcode_state" in data:
  235. logger.debug(
  236. f"[{self.serial_number}] gcode_state: {self._previous_gcode_state} -> {self.state.state}, "
  237. f"file: {self.state.gcode_file}, subtask: {self.state.subtask_name}"
  238. )
  239. # Detect print start (state changes TO RUNNING with a file)
  240. current_file = self.state.gcode_file or self.state.current_print
  241. is_new_print = (
  242. self.state.state == "RUNNING"
  243. and self._previous_gcode_state != "RUNNING"
  244. and current_file
  245. )
  246. # Also detect if file changed while running (new print started)
  247. is_file_change = (
  248. self.state.state == "RUNNING"
  249. and current_file
  250. and current_file != self._previous_gcode_file
  251. and self._previous_gcode_file is not None
  252. )
  253. # Track RUNNING state for more robust completion detection
  254. if self.state.state == "RUNNING" and current_file:
  255. if not self._was_running:
  256. logger.info(f"[{self.serial_number}] Now tracking RUNNING state for {current_file}")
  257. self._was_running = True
  258. self._completion_triggered = False
  259. if is_new_print or is_file_change:
  260. # Clear any old HMS errors when a new print starts
  261. self.state.hms_errors = []
  262. # Reset completion tracking for new print
  263. self._was_running = True
  264. self._completion_triggered = False
  265. if (is_new_print or is_file_change) and self.on_print_start:
  266. logger.info(
  267. f"[{self.serial_number}] PRINT START detected - file: {current_file}, "
  268. f"subtask: {self.state.subtask_name}, is_new: {is_new_print}, is_file_change: {is_file_change}"
  269. )
  270. self.on_print_start({
  271. "filename": current_file,
  272. "subtask_name": self.state.subtask_name,
  273. "raw_data": data,
  274. })
  275. # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
  276. # Use _was_running flag in addition to _previous_gcode_state for more robust detection
  277. # This handles cases where server restarts during a print
  278. should_trigger_completion = (
  279. self.state.state in ("FINISH", "FAILED")
  280. and not self._completion_triggered
  281. and self.on_print_complete
  282. and (
  283. self._previous_gcode_state == "RUNNING" # Normal transition
  284. or (self._was_running and self._previous_gcode_state != self.state.state) # After server restart
  285. )
  286. )
  287. # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)
  288. if (
  289. self.state.state == "IDLE"
  290. and self._previous_gcode_state == "RUNNING"
  291. and not self._completion_triggered
  292. and self.on_print_complete
  293. ):
  294. should_trigger_completion = True
  295. if should_trigger_completion:
  296. if self.state.state == "FINISH":
  297. status = "completed"
  298. elif self.state.state == "FAILED":
  299. status = "failed"
  300. else:
  301. status = "aborted"
  302. logger.info(
  303. f"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, "
  304. f"status: {status}, file: {self._previous_gcode_file or current_file}, "
  305. f"subtask: {self.state.subtask_name}, was_running: {self._was_running}"
  306. )
  307. self._completion_triggered = True
  308. self._was_running = False
  309. self.on_print_complete({
  310. "status": status,
  311. "filename": self._previous_gcode_file or current_file,
  312. "subtask_name": self.state.subtask_name,
  313. "raw_data": data,
  314. })
  315. self._previous_gcode_state = self.state.state
  316. if current_file:
  317. self._previous_gcode_file = current_file
  318. if self.on_state_change:
  319. self.on_state_change(self.state)
  320. def _request_push_all(self):
  321. """Request full status update from printer."""
  322. if self._client:
  323. message = {"pushing": {"command": "pushall"}}
  324. self._client.publish(self.topic_publish, json.dumps(message))
  325. def connect(self):
  326. """Connect to the printer MQTT broker."""
  327. self._client = mqtt.Client(
  328. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  329. client_id=f"bambutrack_{self.serial_number}",
  330. protocol=mqtt.MQTTv311,
  331. )
  332. self._client.username_pw_set("bblp", self.access_code)
  333. self._client.on_connect = self._on_connect
  334. self._client.on_disconnect = self._on_disconnect
  335. self._client.on_message = self._on_message
  336. # TLS setup - Bambu uses self-signed certs
  337. ssl_context = ssl.create_default_context()
  338. ssl_context.check_hostname = False
  339. ssl_context.verify_mode = ssl.CERT_NONE
  340. self._client.tls_set_context(ssl_context)
  341. # Use shorter keepalive (15s) for faster disconnect detection
  342. # Paho considers connection lost after 1.5x keepalive with no response
  343. self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
  344. self._client.loop_start()
  345. def start_print(self, filename: str, plate_id: int = 1):
  346. """Start a print job on the printer.
  347. The file should already be uploaded to /cache/ on the printer via FTP.
  348. """
  349. if self._client and self.state.connected:
  350. # Bambu print command format
  351. # Based on: https://github.com/darkorb/bambu-ftp-and-print
  352. command = {
  353. "print": {
  354. "sequence_id": 0,
  355. "command": "project_file",
  356. "param": f"Metadata/plate_{plate_id}.gcode",
  357. "subtask_name": filename,
  358. "url": f"ftp://{filename}",
  359. "timelapse": False,
  360. "bed_leveling": True,
  361. "flow_cali": True,
  362. "vibration_cali": True,
  363. "layer_inspect": False,
  364. "use_ams": True,
  365. }
  366. }
  367. logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
  368. self._client.publish(self.topic_publish, json.dumps(command))
  369. return True
  370. return False
  371. def stop_print(self) -> bool:
  372. """Stop the current print job."""
  373. if self._client and self.state.connected:
  374. command = {
  375. "print": {
  376. "command": "stop",
  377. "sequence_id": "0"
  378. }
  379. }
  380. self._client.publish(self.topic_publish, json.dumps(command))
  381. logger.info(f"[{self.serial_number}] Sent stop print command")
  382. return True
  383. return False
  384. def disconnect(self):
  385. """Disconnect from the printer."""
  386. if self._client:
  387. self._client.loop_stop()
  388. self._client.disconnect()
  389. self._client = None
  390. self.state.connected = False
  391. def send_command(self, command: dict):
  392. """Send a command to the printer."""
  393. if self._client and self.state.connected:
  394. # Log outgoing message if logging is enabled
  395. if self._logging_enabled:
  396. self._message_log.append(MQTTLogEntry(
  397. timestamp=datetime.now().isoformat(),
  398. topic=self.topic_publish,
  399. direction="out",
  400. payload=command,
  401. ))
  402. self._client.publish(self.topic_publish, json.dumps(command))
  403. def enable_logging(self, enabled: bool = True):
  404. """Enable or disable MQTT message logging."""
  405. self._logging_enabled = enabled
  406. # Don't clear logs when stopping - user can manually clear with clear_logs()
  407. def get_logs(self) -> list[MQTTLogEntry]:
  408. """Get all logged MQTT messages."""
  409. return list(self._message_log)
  410. def clear_logs(self):
  411. """Clear the message log."""
  412. self._message_log.clear()
  413. @property
  414. def logging_enabled(self) -> bool:
  415. """Check if logging is enabled."""
  416. return self._logging_enabled
  417. def _handle_kprofile_response(self, data: dict):
  418. """Handle K-profile response from printer."""
  419. filaments = data.get("filaments", [])
  420. profiles = []
  421. # Log first profile to see what fields the printer returns
  422. if filaments and isinstance(filaments[0], dict):
  423. logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
  424. logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
  425. for i, f in enumerate(filaments):
  426. if isinstance(f, dict):
  427. try:
  428. # cali_idx is the actual slot/calibration index from the printer
  429. cali_idx = f.get("cali_idx", i)
  430. profiles.append(KProfile(
  431. slot_id=cali_idx,
  432. extruder_id=int(f.get("extruder_id", 0)),
  433. nozzle_id=str(f.get("nozzle_id", "")),
  434. nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
  435. filament_id=str(f.get("filament_id", "")),
  436. name=str(f.get("name", "")),
  437. k_value=str(f.get("k_value", "0.000000")),
  438. n_coef=str(f.get("n_coef", "0.000000")),
  439. ams_id=int(f.get("ams_id", 0)),
  440. tray_id=int(f.get("tray_id", -1)),
  441. setting_id=f.get("setting_id"),
  442. ))
  443. except (ValueError, TypeError) as e:
  444. logger.warning(f"Failed to parse K-profile: {e}")
  445. self.state.kprofiles = profiles
  446. self._kprofile_response_data = profiles
  447. # Signal that we received the response
  448. if self._pending_kprofile_response:
  449. self._pending_kprofile_response.set()
  450. logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
  451. async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0) -> list[KProfile]:
  452. """Request K-profiles from the printer.
  453. Args:
  454. nozzle_diameter: Filter by nozzle diameter (e.g., "0.4")
  455. timeout: Timeout in seconds to wait for response
  456. Returns:
  457. List of KProfile objects
  458. """
  459. if not self._client or not self.state.connected:
  460. logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected")
  461. return []
  462. # Set up response event
  463. self._sequence_id += 1
  464. self._pending_kprofile_response = asyncio.Event()
  465. self._kprofile_response_data = None
  466. # Send the command
  467. command = {
  468. "print": {
  469. "command": "extrusion_cali_get",
  470. "filament_id": "",
  471. "nozzle_diameter": nozzle_diameter,
  472. "sequence_id": str(self._sequence_id),
  473. }
  474. }
  475. logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter}")
  476. self._client.publish(self.topic_publish, json.dumps(command))
  477. # Wait for response
  478. try:
  479. await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
  480. return self._kprofile_response_data or []
  481. except asyncio.TimeoutError:
  482. logger.warning(f"[{self.serial_number}] Timeout waiting for K-profiles response")
  483. return []
  484. finally:
  485. self._pending_kprofile_response = None
  486. def set_kprofile(
  487. self,
  488. filament_id: str,
  489. name: str,
  490. k_value: str,
  491. nozzle_diameter: str = "0.4",
  492. nozzle_id: str = "HS00-0.4",
  493. extruder_id: int = 0,
  494. setting_id: str | None = None,
  495. slot_id: int = 0,
  496. ) -> bool:
  497. """Set/update a K-profile on the printer.
  498. Args:
  499. filament_id: Bambu filament identifier
  500. name: Profile name
  501. k_value: Pressure advance value (e.g., "0.020000")
  502. nozzle_diameter: Nozzle diameter (e.g., "0.4")
  503. nozzle_id: Nozzle identifier (e.g., "HS00-0.4")
  504. extruder_id: Extruder ID (0 or 1 for dual nozzle)
  505. setting_id: Existing setting ID for updates, None for new
  506. slot_id: Calibration index (cali_idx) for the profile
  507. Returns:
  508. True if command was sent, False otherwise
  509. """
  510. if not self._client or not self.state.connected:
  511. logger.warning(f"[{self.serial_number}] Cannot set K-profile: not connected")
  512. return False
  513. self._sequence_id += 1
  514. # Build the filament entry - printer uses cali_idx for profile identification
  515. # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
  516. cali_idx = -1 if slot_id == 0 else slot_id
  517. # Generate a setting_id for new profiles (required by printer)
  518. # Format: "PF" + 17 random digits
  519. import random
  520. if not setting_id and slot_id == 0:
  521. setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
  522. filament_entry = {
  523. "ams_id": 0,
  524. "cali_idx": cali_idx,
  525. "extruder_id": extruder_id,
  526. "filament_id": filament_id,
  527. "k_value": k_value,
  528. "n_coef": "0.000000",
  529. "name": name,
  530. "nozzle_diameter": nozzle_diameter,
  531. "nozzle_id": nozzle_id,
  532. "setting_id": setting_id, # Always include setting_id
  533. "tray_id": -1,
  534. }
  535. command = {
  536. "print": {
  537. "command": "extrusion_cali_set",
  538. "filaments": [filament_entry],
  539. "nozzle_diameter": nozzle_diameter,
  540. "sequence_id": str(self._sequence_id),
  541. }
  542. }
  543. command_json = json.dumps(command)
  544. logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0})")
  545. logger.debug(f"[{self.serial_number}] K-profile command: {command_json}")
  546. self._client.publish(self.topic_publish, command_json)
  547. return True
  548. def delete_kprofile(
  549. self,
  550. cali_idx: int,
  551. filament_id: str,
  552. nozzle_id: str,
  553. nozzle_diameter: str = "0.4",
  554. extruder_id: int = 0,
  555. ) -> bool:
  556. """Delete a K-profile from the printer.
  557. Args:
  558. cali_idx: The calibration index (slot_id) of the profile to delete
  559. filament_id: Bambu filament identifier
  560. nozzle_id: Nozzle identifier (e.g., "HH00-0.4")
  561. nozzle_diameter: Nozzle diameter (e.g., "0.4")
  562. extruder_id: Extruder ID (0 or 1 for dual nozzle)
  563. Returns:
  564. True if command was sent, False otherwise
  565. """
  566. if not self._client or not self.state.connected:
  567. logger.warning(f"[{self.serial_number}] Cannot delete K-profile: not connected")
  568. return False
  569. self._sequence_id += 1
  570. command = {
  571. "print": {
  572. "command": "extrusion_cali_del",
  573. "sequence_id": str(self._sequence_id),
  574. "extruder_id": extruder_id,
  575. "nozzle_id": nozzle_id,
  576. "filament_id": filament_id,
  577. "cali_idx": cali_idx,
  578. "nozzle_diameter": nozzle_diameter,
  579. }
  580. }
  581. command_json = json.dumps(command)
  582. logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}")
  583. logger.debug(f"[{self.serial_number}] K-profile delete command: {command_json}")
  584. self._client.publish(self.topic_publish, command_json)
  585. return True