bambu_mqtt.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  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. on_ams_change: Callable[[list], None] | None = None,
  68. ):
  69. self.ip_address = ip_address
  70. self.serial_number = serial_number
  71. self.access_code = access_code
  72. self.on_state_change = on_state_change
  73. self.on_print_start = on_print_start
  74. self.on_print_complete = on_print_complete
  75. self.on_ams_change = on_ams_change
  76. self.state = PrinterState()
  77. self._client: mqtt.Client | None = None
  78. self._loop: asyncio.AbstractEventLoop | None = None
  79. self._previous_gcode_state: str | None = None
  80. self._previous_gcode_file: str | None = None
  81. self._was_running: bool = False # Track if we've seen RUNNING state for current print
  82. self._completion_triggered: bool = False # Prevent duplicate completion triggers
  83. self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
  84. self._logging_enabled: bool = False
  85. self._last_message_time: float = 0.0 # Track when we last received a message
  86. self._previous_ams_hash: str | None = None # Track AMS changes
  87. # K-profile command tracking
  88. self._sequence_id: int = 0
  89. self._pending_kprofile_response: asyncio.Event | None = None
  90. self._kprofile_response_data: list | None = None
  91. @property
  92. def topic_subscribe(self) -> str:
  93. return f"device/{self.serial_number}/report"
  94. @property
  95. def topic_publish(self) -> str:
  96. return f"device/{self.serial_number}/request"
  97. def _on_connect(self, client, userdata, flags, rc, properties=None):
  98. if rc == 0:
  99. self.state.connected = True
  100. client.subscribe(self.topic_subscribe)
  101. # Request full status update
  102. self._request_push_all()
  103. # Prime K-profile request (Bambu printers often ignore first request)
  104. self._prime_kprofile_request()
  105. # Immediately broadcast connection state change
  106. if self.on_state_change:
  107. self.on_state_change(self.state)
  108. else:
  109. self.state.connected = False
  110. def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
  111. # Ignore spurious disconnect callbacks if we've received a message recently
  112. # Paho-mqtt sometimes fires disconnect callbacks while the connection is still active
  113. time_since_last_message = time.time() - self._last_message_time
  114. if time_since_last_message < 30.0 and self._last_message_time > 0:
  115. logger.debug(
  116. f"[{self.serial_number}] Ignoring spurious disconnect (last message {time_since_last_message:.1f}s ago)"
  117. )
  118. return
  119. logger.warning(f"[{self.serial_number}] MQTT disconnected: rc={rc}, flags={disconnect_flags}")
  120. self.state.connected = False
  121. if self.on_state_change:
  122. self.on_state_change(self.state)
  123. def _on_message(self, client, userdata, msg):
  124. try:
  125. payload = json.loads(msg.payload.decode())
  126. # Track last message time - receiving a message proves we're connected
  127. self._last_message_time = time.time()
  128. self.state.connected = True
  129. # Log message if logging is enabled
  130. if self._logging_enabled:
  131. self._message_log.append(MQTTLogEntry(
  132. timestamp=datetime.now().isoformat(),
  133. topic=msg.topic,
  134. direction="in",
  135. payload=payload,
  136. ))
  137. self._process_message(payload)
  138. except json.JSONDecodeError:
  139. pass
  140. def _process_message(self, payload: dict):
  141. """Process incoming MQTT message from printer."""
  142. # Handle top-level AMS data (comes outside of "print" key)
  143. # Wrap in try/except to prevent breaking the MQTT connection
  144. if "ams" in payload:
  145. try:
  146. self._handle_ams_data(payload["ams"])
  147. except Exception as e:
  148. logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
  149. if "print" in payload:
  150. print_data = payload["print"]
  151. # Log when we see gcode_state changes
  152. if "gcode_state" in print_data:
  153. logger.info(
  154. f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
  155. f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
  156. )
  157. # Check for K-profile response (extrusion_cali)
  158. if "command" in print_data:
  159. logger.debug(f"[{self.serial_number}] Received command response: {print_data.get('command')}")
  160. if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
  161. self._handle_kprofile_response(print_data)
  162. self._update_state(print_data)
  163. def _handle_ams_data(self, ams_data: list):
  164. """Handle AMS data changes for Spoolman integration.
  165. This is called when we receive top-level AMS data in MQTT messages.
  166. It detects changes and triggers the callback for Spoolman sync.
  167. """
  168. import hashlib
  169. # Store AMS data in raw_data so it's accessible via API
  170. if "ams" not in self.state.raw_data:
  171. self.state.raw_data["ams"] = ams_data
  172. else:
  173. self.state.raw_data["ams"] = ams_data
  174. # Create a hash of relevant AMS data to detect changes
  175. ams_hash_data = []
  176. for ams_unit in ams_data:
  177. for tray in ams_unit.get("tray", []):
  178. # Include fields that matter for filament tracking
  179. ams_hash_data.append(
  180. f"{ams_unit.get('id')}:{tray.get('id')}:"
  181. f"{tray.get('tray_type')}:{tray.get('tag_uid')}:{tray.get('remain')}"
  182. )
  183. ams_hash = hashlib.md5(":".join(ams_hash_data).encode()).hexdigest()
  184. # Only trigger callback if AMS data actually changed
  185. if ams_hash != self._previous_ams_hash:
  186. self._previous_ams_hash = ams_hash
  187. if self.on_ams_change:
  188. logger.info(f"[{self.serial_number}] AMS data changed, triggering sync callback")
  189. self.on_ams_change(ams_data)
  190. def _update_state(self, data: dict):
  191. """Update printer state from message data."""
  192. previous_state = self.state.state
  193. # Update state fields
  194. if "gcode_state" in data:
  195. self.state.state = data["gcode_state"]
  196. if "gcode_file" in data:
  197. self.state.gcode_file = data["gcode_file"]
  198. self.state.current_print = data["gcode_file"]
  199. if "subtask_name" in data:
  200. self.state.subtask_name = data["subtask_name"]
  201. # Prefer subtask_name as current_print if available
  202. if data["subtask_name"]:
  203. self.state.current_print = data["subtask_name"]
  204. if "subtask_id" in data:
  205. self.state.subtask_id = data["subtask_id"]
  206. if "mc_percent" in data:
  207. self.state.progress = float(data["mc_percent"])
  208. if "mc_remaining_time" in data:
  209. self.state.remaining_time = int(data["mc_remaining_time"])
  210. if "layer_num" in data:
  211. self.state.layer_num = int(data["layer_num"])
  212. if "total_layer_num" in data:
  213. self.state.total_layers = int(data["total_layer_num"])
  214. # Temperature data
  215. temps = {}
  216. # Log all temperature-related fields for debugging (only when we have temp data)
  217. temp_fields = {k: v for k, v in data.items() if 'temp' in k.lower() or 'nozzle' in k.lower()}
  218. if temp_fields and not hasattr(self, '_temp_fields_logged'):
  219. logger.info(f"[{self.serial_number}] Temperature fields in MQTT data: {temp_fields}")
  220. self._temp_fields_logged = True
  221. if "bed_temper" in data:
  222. temps["bed"] = float(data["bed_temper"])
  223. if "bed_target_temper" in data:
  224. temps["bed_target"] = float(data["bed_target_temper"])
  225. if "nozzle_temper" in data:
  226. temps["nozzle"] = float(data["nozzle_temper"])
  227. if "nozzle_target_temper" in data:
  228. temps["nozzle_target"] = float(data["nozzle_target_temper"])
  229. # Second nozzle for dual-extruder printers (H2 series)
  230. # Try multiple possible field names used by different firmware versions
  231. if "nozzle_temper_2" in data:
  232. temps["nozzle_2"] = float(data["nozzle_temper_2"])
  233. elif "right_nozzle_temper" in data:
  234. temps["nozzle_2"] = float(data["right_nozzle_temper"])
  235. if "nozzle_target_temper_2" in data:
  236. temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
  237. elif "right_nozzle_target_temper" in data:
  238. temps["nozzle_2_target"] = float(data["right_nozzle_target_temper"])
  239. # Also check for left nozzle as primary (some H2 models)
  240. if "left_nozzle_temper" in data and "nozzle" not in temps:
  241. temps["nozzle"] = float(data["left_nozzle_temper"])
  242. if "left_nozzle_target_temper" in data and "nozzle_target" not in temps:
  243. temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
  244. if "chamber_temper" in data:
  245. temps["chamber"] = float(data["chamber_temper"])
  246. if temps:
  247. self.state.temperatures = temps
  248. # Parse HMS (Health Management System) errors
  249. if "hms" in data:
  250. hms_list = data["hms"]
  251. self.state.hms_errors = []
  252. if isinstance(hms_list, list):
  253. for hms in hms_list:
  254. if isinstance(hms, dict):
  255. # HMS format: {"attr": code, "code": full_code}
  256. # The code is a hex string, severity is in bits
  257. code = hms.get("code", hms.get("attr", "0"))
  258. if isinstance(code, int):
  259. code = hex(code)
  260. # Parse severity from code (typically last 4 bits indicate level)
  261. try:
  262. code_int = int(str(code).replace("0x", ""), 16) if code else 0
  263. severity = (code_int >> 16) & 0xF # Extract severity bits
  264. module = (code_int >> 24) & 0xFF # Extract module bits
  265. except (ValueError, TypeError):
  266. severity = 3
  267. module = 0
  268. self.state.hms_errors.append(HMSError(
  269. code=str(code),
  270. module=module,
  271. severity=severity if severity > 0 else 3,
  272. ))
  273. # Preserve AMS data when updating raw_data (AMS comes at top level, not in print)
  274. ams_data = self.state.raw_data.get("ams")
  275. self.state.raw_data = data
  276. if ams_data is not None:
  277. self.state.raw_data["ams"] = ams_data
  278. # Log state transitions for debugging
  279. if "gcode_state" in data:
  280. logger.debug(
  281. f"[{self.serial_number}] gcode_state: {self._previous_gcode_state} -> {self.state.state}, "
  282. f"file: {self.state.gcode_file}, subtask: {self.state.subtask_name}"
  283. )
  284. # Detect print start (state changes TO RUNNING with a file)
  285. current_file = self.state.gcode_file or self.state.current_print
  286. is_new_print = (
  287. self.state.state == "RUNNING"
  288. and self._previous_gcode_state != "RUNNING"
  289. and current_file
  290. )
  291. # Also detect if file changed while running (new print started)
  292. is_file_change = (
  293. self.state.state == "RUNNING"
  294. and current_file
  295. and current_file != self._previous_gcode_file
  296. and self._previous_gcode_file is not None
  297. )
  298. # Track RUNNING state for more robust completion detection
  299. if self.state.state == "RUNNING" and current_file:
  300. if not self._was_running:
  301. logger.info(f"[{self.serial_number}] Now tracking RUNNING state for {current_file}")
  302. self._was_running = True
  303. self._completion_triggered = False
  304. if is_new_print or is_file_change:
  305. # Clear any old HMS errors when a new print starts
  306. self.state.hms_errors = []
  307. # Reset completion tracking for new print
  308. self._was_running = True
  309. self._completion_triggered = False
  310. if (is_new_print or is_file_change) and self.on_print_start:
  311. logger.info(
  312. f"[{self.serial_number}] PRINT START detected - file: {current_file}, "
  313. f"subtask: {self.state.subtask_name}, is_new: {is_new_print}, is_file_change: {is_file_change}"
  314. )
  315. self.on_print_start({
  316. "filename": current_file,
  317. "subtask_name": self.state.subtask_name,
  318. "raw_data": data,
  319. })
  320. # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
  321. # Use _was_running flag in addition to _previous_gcode_state for more robust detection
  322. # This handles cases where server restarts during a print
  323. should_trigger_completion = (
  324. self.state.state in ("FINISH", "FAILED")
  325. and not self._completion_triggered
  326. and self.on_print_complete
  327. and (
  328. self._previous_gcode_state == "RUNNING" # Normal transition
  329. or (self._was_running and self._previous_gcode_state != self.state.state) # After server restart
  330. )
  331. )
  332. # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)
  333. if (
  334. self.state.state == "IDLE"
  335. and self._previous_gcode_state == "RUNNING"
  336. and not self._completion_triggered
  337. and self.on_print_complete
  338. ):
  339. should_trigger_completion = True
  340. if should_trigger_completion:
  341. if self.state.state == "FINISH":
  342. status = "completed"
  343. elif self.state.state == "FAILED":
  344. status = "failed"
  345. else:
  346. status = "aborted"
  347. logger.info(
  348. f"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, "
  349. f"status: {status}, file: {self._previous_gcode_file or current_file}, "
  350. f"subtask: {self.state.subtask_name}, was_running: {self._was_running}"
  351. )
  352. self._completion_triggered = True
  353. self._was_running = False
  354. self.on_print_complete({
  355. "status": status,
  356. "filename": self._previous_gcode_file or current_file,
  357. "subtask_name": self.state.subtask_name,
  358. "raw_data": data,
  359. })
  360. self._previous_gcode_state = self.state.state
  361. if current_file:
  362. self._previous_gcode_file = current_file
  363. if self.on_state_change:
  364. self.on_state_change(self.state)
  365. def _request_push_all(self):
  366. """Request full status update from printer."""
  367. if self._client:
  368. message = {"pushing": {"command": "pushall"}}
  369. self._client.publish(self.topic_publish, json.dumps(message))
  370. def _prime_kprofile_request(self):
  371. """Send a priming K-profile request on connect.
  372. Bambu printers often ignore the first K-profile request after connection,
  373. so we send a dummy request on connect to 'prime' the system.
  374. """
  375. if self._client:
  376. self._sequence_id += 1
  377. command = {
  378. "print": {
  379. "command": "extrusion_cali_get",
  380. "filament_id": "",
  381. "nozzle_diameter": "0.4",
  382. "sequence_id": str(self._sequence_id),
  383. }
  384. }
  385. logger.debug(f"[{self.serial_number}] Sending K-profile priming request")
  386. self._client.publish(self.topic_publish, json.dumps(command))
  387. def connect(self, loop: asyncio.AbstractEventLoop | None = None):
  388. """Connect to the printer MQTT broker.
  389. Args:
  390. loop: The asyncio event loop to use for thread-safe callbacks.
  391. If not provided, will try to get the running loop.
  392. """
  393. self._loop = loop
  394. self._client = mqtt.Client(
  395. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  396. client_id=f"bambutrack_{self.serial_number}",
  397. protocol=mqtt.MQTTv311,
  398. )
  399. self._client.username_pw_set("bblp", self.access_code)
  400. self._client.on_connect = self._on_connect
  401. self._client.on_disconnect = self._on_disconnect
  402. self._client.on_message = self._on_message
  403. # TLS setup - Bambu uses self-signed certs
  404. ssl_context = ssl.create_default_context()
  405. ssl_context.check_hostname = False
  406. ssl_context.verify_mode = ssl.CERT_NONE
  407. self._client.tls_set_context(ssl_context)
  408. # Use shorter keepalive (15s) for faster disconnect detection
  409. # Paho considers connection lost after 1.5x keepalive with no response
  410. self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
  411. self._client.loop_start()
  412. def start_print(self, filename: str, plate_id: int = 1):
  413. """Start a print job on the printer.
  414. The file should already be uploaded to /cache/ on the printer via FTP.
  415. """
  416. if self._client and self.state.connected:
  417. # Bambu print command format
  418. # Based on: https://github.com/darkorb/bambu-ftp-and-print
  419. command = {
  420. "print": {
  421. "sequence_id": 0,
  422. "command": "project_file",
  423. "param": f"Metadata/plate_{plate_id}.gcode",
  424. "subtask_name": filename,
  425. "url": f"ftp://{filename}",
  426. "timelapse": False,
  427. "bed_leveling": True,
  428. "flow_cali": True,
  429. "vibration_cali": True,
  430. "layer_inspect": False,
  431. "use_ams": True,
  432. }
  433. }
  434. logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
  435. self._client.publish(self.topic_publish, json.dumps(command))
  436. return True
  437. return False
  438. def stop_print(self) -> bool:
  439. """Stop the current print job."""
  440. if self._client and self.state.connected:
  441. command = {
  442. "print": {
  443. "command": "stop",
  444. "sequence_id": "0"
  445. }
  446. }
  447. self._client.publish(self.topic_publish, json.dumps(command))
  448. logger.info(f"[{self.serial_number}] Sent stop print command")
  449. return True
  450. return False
  451. def disconnect(self):
  452. """Disconnect from the printer."""
  453. if self._client:
  454. self._client.loop_stop()
  455. self._client.disconnect()
  456. self._client = None
  457. self.state.connected = False
  458. def send_command(self, command: dict):
  459. """Send a command to the printer."""
  460. if self._client and self.state.connected:
  461. # Log outgoing message if logging is enabled
  462. if self._logging_enabled:
  463. self._message_log.append(MQTTLogEntry(
  464. timestamp=datetime.now().isoformat(),
  465. topic=self.topic_publish,
  466. direction="out",
  467. payload=command,
  468. ))
  469. self._client.publish(self.topic_publish, json.dumps(command))
  470. def enable_logging(self, enabled: bool = True):
  471. """Enable or disable MQTT message logging."""
  472. self._logging_enabled = enabled
  473. # Don't clear logs when stopping - user can manually clear with clear_logs()
  474. def get_logs(self) -> list[MQTTLogEntry]:
  475. """Get all logged MQTT messages."""
  476. return list(self._message_log)
  477. def clear_logs(self):
  478. """Clear the message log."""
  479. self._message_log.clear()
  480. @property
  481. def logging_enabled(self) -> bool:
  482. """Check if logging is enabled."""
  483. return self._logging_enabled
  484. def _handle_kprofile_response(self, data: dict):
  485. """Handle K-profile response from printer."""
  486. filaments = data.get("filaments", [])
  487. profiles = []
  488. # Log first profile to see what fields the printer returns
  489. if filaments and isinstance(filaments[0], dict):
  490. logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
  491. logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
  492. for i, f in enumerate(filaments):
  493. if isinstance(f, dict):
  494. try:
  495. # cali_idx is the actual slot/calibration index from the printer
  496. cali_idx = f.get("cali_idx", i)
  497. profiles.append(KProfile(
  498. slot_id=cali_idx,
  499. extruder_id=int(f.get("extruder_id", 0)),
  500. nozzle_id=str(f.get("nozzle_id", "")),
  501. nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
  502. filament_id=str(f.get("filament_id", "")),
  503. name=str(f.get("name", "")),
  504. k_value=str(f.get("k_value", "0.000000")),
  505. n_coef=str(f.get("n_coef", "0.000000")),
  506. ams_id=int(f.get("ams_id", 0)),
  507. tray_id=int(f.get("tray_id", -1)),
  508. setting_id=f.get("setting_id"),
  509. ))
  510. except (ValueError, TypeError) as e:
  511. logger.warning(f"Failed to parse K-profile: {e}")
  512. self.state.kprofiles = profiles
  513. self._kprofile_response_data = profiles
  514. # Signal that we received the response
  515. # Use thread-safe method since MQTT callbacks run in a different thread
  516. if self._pending_kprofile_response:
  517. if self._loop and self._loop.is_running():
  518. self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
  519. else:
  520. # Fallback for when loop is not available
  521. self._pending_kprofile_response.set()
  522. logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
  523. async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3) -> list[KProfile]:
  524. """Request K-profiles from the printer with retry logic.
  525. Bambu printers sometimes ignore the first K-profile request, so we
  526. implement retry logic to ensure reliable retrieval.
  527. Args:
  528. nozzle_diameter: Filter by nozzle diameter (e.g., "0.4")
  529. timeout: Timeout in seconds to wait for each response attempt
  530. max_retries: Maximum number of retry attempts
  531. Returns:
  532. List of KProfile objects
  533. """
  534. if not self._client or not self.state.connected:
  535. logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected")
  536. return []
  537. # Capture current event loop for thread-safe callback
  538. try:
  539. self._loop = asyncio.get_running_loop()
  540. except RuntimeError:
  541. logger.warning(f"[{self.serial_number}] No running event loop")
  542. return []
  543. for attempt in range(max_retries):
  544. # Set up response event for this attempt
  545. self._sequence_id += 1
  546. self._pending_kprofile_response = asyncio.Event()
  547. self._kprofile_response_data = None
  548. # Send the command
  549. command = {
  550. "print": {
  551. "command": "extrusion_cali_get",
  552. "filament_id": "",
  553. "nozzle_diameter": nozzle_diameter,
  554. "sequence_id": str(self._sequence_id),
  555. }
  556. }
  557. logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
  558. self._client.publish(self.topic_publish, json.dumps(command))
  559. # Wait for response
  560. try:
  561. await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
  562. profiles = self._kprofile_response_data or []
  563. logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles on attempt {attempt + 1}")
  564. return profiles
  565. except asyncio.TimeoutError:
  566. logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}")
  567. if attempt < max_retries - 1:
  568. # Brief delay before retry
  569. await asyncio.sleep(0.5)
  570. finally:
  571. self._pending_kprofile_response = None
  572. logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts")
  573. return []
  574. def set_kprofile(
  575. self,
  576. filament_id: str,
  577. name: str,
  578. k_value: str,
  579. nozzle_diameter: str = "0.4",
  580. nozzle_id: str = "HS00-0.4",
  581. extruder_id: int = 0,
  582. setting_id: str | None = None,
  583. slot_id: int = 0,
  584. cali_idx: int | None = None,
  585. ) -> bool:
  586. """Set/update a K-profile on the printer.
  587. Args:
  588. filament_id: Bambu filament identifier
  589. name: Profile name
  590. k_value: Pressure advance value (e.g., "0.020000")
  591. nozzle_diameter: Nozzle diameter (e.g., "0.4")
  592. nozzle_id: Nozzle identifier (e.g., "HS00-0.4")
  593. extruder_id: Extruder ID (0 or 1 for dual nozzle)
  594. setting_id: Existing setting ID for updates, None for new
  595. slot_id: Calibration index (cali_idx) for the profile
  596. cali_idx: For H2D edits, the existing slot being edited (enables in-place edit)
  597. Returns:
  598. True if command was sent, False otherwise
  599. """
  600. if not self._client or not self.state.connected:
  601. logger.warning(f"[{self.serial_number}] Cannot set K-profile: not connected")
  602. return False
  603. self._sequence_id += 1
  604. # Detect printer type by serial number prefix
  605. # X1C/P1/A1 series (single nozzle): serial starts with "00M", "00W", "01P", "01S", "03W", etc.
  606. # H2D series (dual nozzle): serial starts with "094"
  607. is_dual_nozzle = self.serial_number.startswith("094")
  608. # For H2D edits, use empty setting_id per OrcaSlicer sniff
  609. # For new profiles, generate a setting_id
  610. import secrets
  611. if cali_idx is not None:
  612. # Edit mode - use empty setting_id per OrcaSlicer sniff
  613. setting_id = ""
  614. elif not setting_id and slot_id == 0:
  615. # New profile - generate setting_id
  616. setting_id = f"PFUS{secrets.token_hex(7)}" # 7 bytes = 14 hex chars
  617. if is_dual_nozzle:
  618. # H2D format - exact OrcaSlicer format (captured via MQTT sniffing)
  619. # For edits: include cali_idx (existing slot), slot_id=0, setting_id=""
  620. # For new profiles: no cali_idx, slot_id=0, setting_id=generated
  621. filament_entry = {
  622. "ams_id": 0,
  623. "extruder_id": extruder_id,
  624. "filament_id": filament_id,
  625. "k_value": k_value,
  626. "n_coef": "0.000000",
  627. "name": name,
  628. "nozzle_diameter": nozzle_diameter,
  629. "nozzle_id": nozzle_id,
  630. "setting_id": setting_id if setting_id else "",
  631. "slot_id": slot_id,
  632. "tray_id": -1,
  633. }
  634. # For edits, add cali_idx field (position matters - alphabetical order)
  635. if cali_idx is not None:
  636. # Insert cali_idx in alphabetical position (after ams_id, before extruder_id)
  637. # n_coef must be "0.000000" for H2D edits (matches OrcaSlicer sniff)
  638. filament_entry = {
  639. "ams_id": 0,
  640. "cali_idx": cali_idx,
  641. "extruder_id": extruder_id,
  642. "filament_id": filament_id,
  643. "k_value": k_value,
  644. "n_coef": "0.000000",
  645. "name": name,
  646. "nozzle_diameter": nozzle_diameter,
  647. "nozzle_id": nozzle_id,
  648. "setting_id": "",
  649. "slot_id": 0,
  650. "tray_id": -1,
  651. }
  652. command = {
  653. "print": {
  654. "command": "extrusion_cali_set",
  655. "filaments": [filament_entry],
  656. "nozzle_diameter": nozzle_diameter,
  657. "sequence_id": str(self._sequence_id),
  658. }
  659. }
  660. else:
  661. # X1C/P1/A1 format - based on actual X1C profile data:
  662. # - n_coef: "1.000000" (NOT 0.000000 like H2D)
  663. # - nozzle_id: "" (empty string, NOT the nozzle type)
  664. # - tray_id: -1 (NOT 0)
  665. filament_entry = {
  666. "ams_id": 0,
  667. "extruder_id": 0, # X1C is single nozzle
  668. "filament_id": filament_id,
  669. "k_value": k_value,
  670. "n_coef": "1.000000", # X1C uses 1.0, not 0.0
  671. "name": name,
  672. "nozzle_diameter": nozzle_diameter,
  673. "nozzle_id": "", # X1C uses empty string
  674. "setting_id": setting_id,
  675. "slot_id": slot_id,
  676. "tray_id": -1, # X1C uses -1
  677. }
  678. command = {
  679. "print": {
  680. "command": "extrusion_cali_set",
  681. "filaments": [filament_entry],
  682. "nozzle_diameter": nozzle_diameter,
  683. "sequence_id": str(self._sequence_id),
  684. }
  685. }
  686. command_json = json.dumps(command)
  687. logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0}, dual={is_dual_nozzle})")
  688. logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}")
  689. # Use QoS 1 for reliable delivery (at least once)
  690. self._client.publish(self.topic_publish, command_json, qos=1)
  691. return True
  692. def delete_kprofile(
  693. self,
  694. cali_idx: int,
  695. filament_id: str,
  696. nozzle_id: str,
  697. nozzle_diameter: str = "0.4",
  698. extruder_id: int = 0,
  699. setting_id: str | None = None,
  700. ) -> bool:
  701. """Delete a K-profile from the printer.
  702. Args:
  703. cali_idx: The calibration index (slot_id) of the profile to delete
  704. filament_id: Bambu filament identifier
  705. nozzle_id: Nozzle identifier (e.g., "HH00-0.4")
  706. nozzle_diameter: Nozzle diameter (e.g., "0.4")
  707. extruder_id: Extruder ID (0 or 1 for dual nozzle)
  708. setting_id: Unique setting identifier (for X1C series)
  709. Returns:
  710. True if command was sent, False otherwise
  711. """
  712. if not self._client or not self.state.connected:
  713. logger.warning(f"[{self.serial_number}] Cannot delete K-profile: not connected")
  714. return False
  715. self._sequence_id += 1
  716. # Detect printer type by serial number prefix
  717. # H2D series (dual nozzle): serial starts with "094"
  718. is_dual_nozzle = self.serial_number.startswith("094")
  719. if is_dual_nozzle:
  720. # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
  721. command = {
  722. "print": {
  723. "command": "extrusion_cali_del",
  724. "sequence_id": str(self._sequence_id),
  725. "extruder_id": extruder_id,
  726. "nozzle_id": nozzle_id,
  727. "filament_id": filament_id,
  728. "cali_idx": cali_idx,
  729. "nozzle_diameter": nozzle_diameter,
  730. }
  731. }
  732. else:
  733. # X1C/P1/A1 format: uses setting_id, nozzle_diameter, no extruder/nozzle_id fields
  734. command = {
  735. "print": {
  736. "command": "extrusion_cali_del",
  737. "sequence_id": str(self._sequence_id),
  738. "filament_id": filament_id,
  739. "cali_idx": cali_idx,
  740. "setting_id": setting_id,
  741. "nozzle_diameter": nozzle_diameter,
  742. }
  743. }
  744. command_json = json.dumps(command)
  745. logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, dual={is_dual_nozzle}")
  746. logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}")
  747. # Use QoS 1 for reliable delivery (at least once)
  748. self._client.publish(self.topic_publish, command_json, qos=1)
  749. return True