bambu_mqtt.py 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167
  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. # Handle AMS data that comes inside print key
  158. if "ams" in print_data:
  159. try:
  160. self._handle_ams_data(print_data["ams"])
  161. except Exception as e:
  162. logger.error(f"[{self.serial_number}] Error handling AMS data from print: {e}")
  163. # Handle vt_tray (virtual tray / external spool) data
  164. if "vt_tray" in print_data:
  165. self.state.raw_data["vt_tray"] = print_data["vt_tray"]
  166. # Check for K-profile response (extrusion_cali)
  167. if "command" in print_data:
  168. logger.debug(f"[{self.serial_number}] Received command response: {print_data.get('command')}")
  169. if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
  170. self._handle_kprofile_response(print_data)
  171. self._update_state(print_data)
  172. def _handle_ams_data(self, ams_data):
  173. """Handle AMS data changes for Spoolman integration.
  174. This is called when we receive top-level AMS data in MQTT messages.
  175. It detects changes and triggers the callback for Spoolman sync.
  176. """
  177. import hashlib
  178. # Handle nested ams structure: {"ams": {"ams": [...]}} or {"ams": [...]}
  179. if isinstance(ams_data, dict) and "ams" in ams_data:
  180. ams_list = ams_data["ams"]
  181. elif isinstance(ams_data, list):
  182. ams_list = ams_data
  183. else:
  184. logger.warning(f"[{self.serial_number}] Unexpected AMS data format: {type(ams_data)}")
  185. return
  186. # Store AMS data in raw_data so it's accessible via API
  187. self.state.raw_data["ams"] = ams_list
  188. logger.debug(f"[{self.serial_number}] Stored AMS data with {len(ams_list)} units")
  189. # Create a hash of relevant AMS data to detect changes
  190. ams_hash_data = []
  191. for ams_unit in ams_list:
  192. for tray in ams_unit.get("tray", []):
  193. # Include fields that matter for filament tracking
  194. ams_hash_data.append(
  195. f"{ams_unit.get('id')}:{tray.get('id')}:"
  196. f"{tray.get('tray_type')}:{tray.get('tag_uid')}:{tray.get('remain')}"
  197. )
  198. ams_hash = hashlib.md5(":".join(ams_hash_data).encode()).hexdigest()
  199. # Only trigger callback if AMS data actually changed
  200. if ams_hash != self._previous_ams_hash:
  201. self._previous_ams_hash = ams_hash
  202. if self.on_ams_change:
  203. logger.info(f"[{self.serial_number}] AMS data changed, triggering sync callback")
  204. self.on_ams_change(ams_list)
  205. def _update_state(self, data: dict):
  206. """Update printer state from message data."""
  207. previous_state = self.state.state
  208. # Update state fields
  209. if "gcode_state" in data:
  210. self.state.state = data["gcode_state"]
  211. if "gcode_file" in data:
  212. self.state.gcode_file = data["gcode_file"]
  213. self.state.current_print = data["gcode_file"]
  214. if "subtask_name" in data:
  215. self.state.subtask_name = data["subtask_name"]
  216. # Prefer subtask_name as current_print if available
  217. if data["subtask_name"]:
  218. self.state.current_print = data["subtask_name"]
  219. if "subtask_id" in data:
  220. self.state.subtask_id = data["subtask_id"]
  221. if "mc_percent" in data:
  222. self.state.progress = float(data["mc_percent"])
  223. if "mc_remaining_time" in data:
  224. self.state.remaining_time = int(data["mc_remaining_time"])
  225. if "layer_num" in data:
  226. self.state.layer_num = int(data["layer_num"])
  227. if "total_layer_num" in data:
  228. self.state.total_layers = int(data["total_layer_num"])
  229. # Temperature data
  230. temps = {}
  231. # Log all temperature-related fields for debugging (only when we have temp data)
  232. temp_fields = {k: v for k, v in data.items() if 'temp' in k.lower() or 'nozzle' in k.lower()}
  233. if temp_fields and not hasattr(self, '_temp_fields_logged'):
  234. logger.info(f"[{self.serial_number}] Temperature fields in MQTT data: {temp_fields}")
  235. self._temp_fields_logged = True
  236. if "bed_temper" in data:
  237. temps["bed"] = float(data["bed_temper"])
  238. if "bed_target_temper" in data:
  239. temps["bed_target"] = float(data["bed_target_temper"])
  240. if "nozzle_temper" in data:
  241. temps["nozzle"] = float(data["nozzle_temper"])
  242. if "nozzle_target_temper" in data:
  243. temps["nozzle_target"] = float(data["nozzle_target_temper"])
  244. # Second nozzle for dual-extruder printers (H2 series)
  245. # Try multiple possible field names used by different firmware versions
  246. if "nozzle_temper_2" in data:
  247. temps["nozzle_2"] = float(data["nozzle_temper_2"])
  248. elif "right_nozzle_temper" in data:
  249. temps["nozzle_2"] = float(data["right_nozzle_temper"])
  250. if "nozzle_target_temper_2" in data:
  251. temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
  252. elif "right_nozzle_target_temper" in data:
  253. temps["nozzle_2_target"] = float(data["right_nozzle_target_temper"])
  254. # Also check for left nozzle as primary (some H2 models)
  255. if "left_nozzle_temper" in data and "nozzle" not in temps:
  256. temps["nozzle"] = float(data["left_nozzle_temper"])
  257. if "left_nozzle_target_temper" in data and "nozzle_target" not in temps:
  258. temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
  259. if "chamber_temper" in data:
  260. temps["chamber"] = float(data["chamber_temper"])
  261. if temps:
  262. self.state.temperatures = temps
  263. # Parse HMS (Health Management System) errors
  264. if "hms" in data:
  265. hms_list = data["hms"]
  266. self.state.hms_errors = []
  267. if isinstance(hms_list, list):
  268. for hms in hms_list:
  269. if isinstance(hms, dict):
  270. # HMS format: {"attr": code, "code": full_code}
  271. # The code is a hex string, severity is in bits
  272. code = hms.get("code", hms.get("attr", "0"))
  273. if isinstance(code, int):
  274. code = hex(code)
  275. # Parse severity from code (typically last 4 bits indicate level)
  276. try:
  277. code_int = int(str(code).replace("0x", ""), 16) if code else 0
  278. severity = (code_int >> 16) & 0xF # Extract severity bits
  279. module = (code_int >> 24) & 0xFF # Extract module bits
  280. except (ValueError, TypeError):
  281. severity = 3
  282. module = 0
  283. self.state.hms_errors.append(HMSError(
  284. code=str(code),
  285. module=module,
  286. severity=severity if severity > 0 else 3,
  287. ))
  288. # Preserve AMS and vt_tray data when updating raw_data
  289. ams_data = self.state.raw_data.get("ams")
  290. vt_tray_data = self.state.raw_data.get("vt_tray")
  291. self.state.raw_data = data
  292. if ams_data is not None:
  293. self.state.raw_data["ams"] = ams_data
  294. if vt_tray_data is not None:
  295. self.state.raw_data["vt_tray"] = vt_tray_data
  296. # Log state transitions for debugging
  297. if "gcode_state" in data:
  298. logger.debug(
  299. f"[{self.serial_number}] gcode_state: {self._previous_gcode_state} -> {self.state.state}, "
  300. f"file: {self.state.gcode_file}, subtask: {self.state.subtask_name}"
  301. )
  302. # Detect print start (state changes TO RUNNING with a file)
  303. current_file = self.state.gcode_file or self.state.current_print
  304. is_new_print = (
  305. self.state.state == "RUNNING"
  306. and self._previous_gcode_state != "RUNNING"
  307. and current_file
  308. )
  309. # Also detect if file changed while running (new print started)
  310. is_file_change = (
  311. self.state.state == "RUNNING"
  312. and current_file
  313. and current_file != self._previous_gcode_file
  314. and self._previous_gcode_file is not None
  315. )
  316. # Track RUNNING state for more robust completion detection
  317. if self.state.state == "RUNNING" and current_file:
  318. if not self._was_running:
  319. logger.info(f"[{self.serial_number}] Now tracking RUNNING state for {current_file}")
  320. self._was_running = True
  321. self._completion_triggered = False
  322. if is_new_print or is_file_change:
  323. # Clear any old HMS errors when a new print starts
  324. self.state.hms_errors = []
  325. # Reset completion tracking for new print
  326. self._was_running = True
  327. self._completion_triggered = False
  328. if (is_new_print or is_file_change) and self.on_print_start:
  329. logger.info(
  330. f"[{self.serial_number}] PRINT START detected - file: {current_file}, "
  331. f"subtask: {self.state.subtask_name}, is_new: {is_new_print}, is_file_change: {is_file_change}"
  332. )
  333. self.on_print_start({
  334. "filename": current_file,
  335. "subtask_name": self.state.subtask_name,
  336. "raw_data": data,
  337. })
  338. # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
  339. # Use _was_running flag in addition to _previous_gcode_state for more robust detection
  340. # This handles cases where server restarts during a print
  341. should_trigger_completion = (
  342. self.state.state in ("FINISH", "FAILED")
  343. and not self._completion_triggered
  344. and self.on_print_complete
  345. and (
  346. self._previous_gcode_state == "RUNNING" # Normal transition
  347. or (self._was_running and self._previous_gcode_state != self.state.state) # After server restart
  348. )
  349. )
  350. # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)
  351. if (
  352. self.state.state == "IDLE"
  353. and self._previous_gcode_state == "RUNNING"
  354. and not self._completion_triggered
  355. and self.on_print_complete
  356. ):
  357. should_trigger_completion = True
  358. if should_trigger_completion:
  359. if self.state.state == "FINISH":
  360. status = "completed"
  361. elif self.state.state == "FAILED":
  362. status = "failed"
  363. else:
  364. status = "aborted"
  365. logger.info(
  366. f"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, "
  367. f"status: {status}, file: {self._previous_gcode_file or current_file}, "
  368. f"subtask: {self.state.subtask_name}, was_running: {self._was_running}"
  369. )
  370. self._completion_triggered = True
  371. self._was_running = False
  372. self.on_print_complete({
  373. "status": status,
  374. "filename": self._previous_gcode_file or current_file,
  375. "subtask_name": self.state.subtask_name,
  376. "raw_data": data,
  377. })
  378. self._previous_gcode_state = self.state.state
  379. if current_file:
  380. self._previous_gcode_file = current_file
  381. if self.on_state_change:
  382. self.on_state_change(self.state)
  383. def _request_push_all(self):
  384. """Request full status update from printer."""
  385. if self._client:
  386. message = {"pushing": {"command": "pushall"}}
  387. self._client.publish(self.topic_publish, json.dumps(message))
  388. def _prime_kprofile_request(self):
  389. """Send a priming K-profile request on connect.
  390. Bambu printers often ignore the first K-profile request after connection,
  391. so we send a dummy request on connect to 'prime' the system.
  392. """
  393. if self._client:
  394. self._sequence_id += 1
  395. command = {
  396. "print": {
  397. "command": "extrusion_cali_get",
  398. "filament_id": "",
  399. "nozzle_diameter": "0.4",
  400. "sequence_id": str(self._sequence_id),
  401. }
  402. }
  403. logger.debug(f"[{self.serial_number}] Sending K-profile priming request")
  404. self._client.publish(self.topic_publish, json.dumps(command))
  405. def connect(self, loop: asyncio.AbstractEventLoop | None = None):
  406. """Connect to the printer MQTT broker.
  407. Args:
  408. loop: The asyncio event loop to use for thread-safe callbacks.
  409. If not provided, will try to get the running loop.
  410. """
  411. self._loop = loop
  412. self._client = mqtt.Client(
  413. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  414. client_id=f"bambutrack_{self.serial_number}",
  415. protocol=mqtt.MQTTv311,
  416. )
  417. self._client.username_pw_set("bblp", self.access_code)
  418. self._client.on_connect = self._on_connect
  419. self._client.on_disconnect = self._on_disconnect
  420. self._client.on_message = self._on_message
  421. # TLS setup - Bambu uses self-signed certs
  422. ssl_context = ssl.create_default_context()
  423. ssl_context.check_hostname = False
  424. ssl_context.verify_mode = ssl.CERT_NONE
  425. self._client.tls_set_context(ssl_context)
  426. # Use shorter keepalive (15s) for faster disconnect detection
  427. # Paho considers connection lost after 1.5x keepalive with no response
  428. self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
  429. self._client.loop_start()
  430. def start_print(self, filename: str, plate_id: int = 1):
  431. """Start a print job on the printer.
  432. The file should already be uploaded to /cache/ on the printer via FTP.
  433. """
  434. if self._client and self.state.connected:
  435. # Bambu print command format
  436. # Based on: https://github.com/darkorb/bambu-ftp-and-print
  437. command = {
  438. "print": {
  439. "sequence_id": 0,
  440. "command": "project_file",
  441. "param": f"Metadata/plate_{plate_id}.gcode",
  442. "subtask_name": filename,
  443. "url": f"ftp://{filename}",
  444. "timelapse": False,
  445. "bed_leveling": True,
  446. "flow_cali": True,
  447. "vibration_cali": True,
  448. "layer_inspect": False,
  449. "use_ams": True,
  450. }
  451. }
  452. logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
  453. self._client.publish(self.topic_publish, json.dumps(command))
  454. return True
  455. return False
  456. def stop_print(self) -> bool:
  457. """Stop the current print job."""
  458. if self._client and self.state.connected:
  459. command = {
  460. "print": {
  461. "command": "stop",
  462. "sequence_id": "0"
  463. }
  464. }
  465. self._client.publish(self.topic_publish, json.dumps(command))
  466. logger.info(f"[{self.serial_number}] Sent stop print command")
  467. return True
  468. return False
  469. def disconnect(self):
  470. """Disconnect from the printer."""
  471. if self._client:
  472. self._client.loop_stop()
  473. self._client.disconnect()
  474. self._client = None
  475. self.state.connected = False
  476. def send_command(self, command: dict):
  477. """Send a command to the printer."""
  478. if self._client and self.state.connected:
  479. # Log outgoing message if logging is enabled
  480. if self._logging_enabled:
  481. self._message_log.append(MQTTLogEntry(
  482. timestamp=datetime.now().isoformat(),
  483. topic=self.topic_publish,
  484. direction="out",
  485. payload=command,
  486. ))
  487. self._client.publish(self.topic_publish, json.dumps(command))
  488. def enable_logging(self, enabled: bool = True):
  489. """Enable or disable MQTT message logging."""
  490. self._logging_enabled = enabled
  491. # Don't clear logs when stopping - user can manually clear with clear_logs()
  492. def get_logs(self) -> list[MQTTLogEntry]:
  493. """Get all logged MQTT messages."""
  494. return list(self._message_log)
  495. def clear_logs(self):
  496. """Clear the message log."""
  497. self._message_log.clear()
  498. @property
  499. def logging_enabled(self) -> bool:
  500. """Check if logging is enabled."""
  501. return self._logging_enabled
  502. def _handle_kprofile_response(self, data: dict):
  503. """Handle K-profile response from printer."""
  504. filaments = data.get("filaments", [])
  505. profiles = []
  506. # Log first profile to see what fields the printer returns
  507. if filaments and isinstance(filaments[0], dict):
  508. logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
  509. logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
  510. for i, f in enumerate(filaments):
  511. if isinstance(f, dict):
  512. try:
  513. # cali_idx is the actual slot/calibration index from the printer
  514. cali_idx = f.get("cali_idx", i)
  515. profiles.append(KProfile(
  516. slot_id=cali_idx,
  517. extruder_id=int(f.get("extruder_id", 0)),
  518. nozzle_id=str(f.get("nozzle_id", "")),
  519. nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
  520. filament_id=str(f.get("filament_id", "")),
  521. name=str(f.get("name", "")),
  522. k_value=str(f.get("k_value", "0.000000")),
  523. n_coef=str(f.get("n_coef", "0.000000")),
  524. ams_id=int(f.get("ams_id", 0)),
  525. tray_id=int(f.get("tray_id", -1)),
  526. setting_id=f.get("setting_id"),
  527. ))
  528. except (ValueError, TypeError) as e:
  529. logger.warning(f"Failed to parse K-profile: {e}")
  530. self.state.kprofiles = profiles
  531. self._kprofile_response_data = profiles
  532. # Signal that we received the response
  533. # Use thread-safe method since MQTT callbacks run in a different thread
  534. if self._pending_kprofile_response:
  535. if self._loop and self._loop.is_running():
  536. self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
  537. else:
  538. # Fallback for when loop is not available
  539. self._pending_kprofile_response.set()
  540. logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
  541. async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3) -> list[KProfile]:
  542. """Request K-profiles from the printer with retry logic.
  543. Bambu printers sometimes ignore the first K-profile request, so we
  544. implement retry logic to ensure reliable retrieval.
  545. Args:
  546. nozzle_diameter: Filter by nozzle diameter (e.g., "0.4")
  547. timeout: Timeout in seconds to wait for each response attempt
  548. max_retries: Maximum number of retry attempts
  549. Returns:
  550. List of KProfile objects
  551. """
  552. if not self._client or not self.state.connected:
  553. logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected")
  554. return []
  555. # Capture current event loop for thread-safe callback
  556. try:
  557. self._loop = asyncio.get_running_loop()
  558. except RuntimeError:
  559. logger.warning(f"[{self.serial_number}] No running event loop")
  560. return []
  561. for attempt in range(max_retries):
  562. # Set up response event for this attempt
  563. self._sequence_id += 1
  564. self._pending_kprofile_response = asyncio.Event()
  565. self._kprofile_response_data = None
  566. # Send the command
  567. command = {
  568. "print": {
  569. "command": "extrusion_cali_get",
  570. "filament_id": "",
  571. "nozzle_diameter": nozzle_diameter,
  572. "sequence_id": str(self._sequence_id),
  573. }
  574. }
  575. logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
  576. self._client.publish(self.topic_publish, json.dumps(command))
  577. # Wait for response
  578. try:
  579. await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
  580. profiles = self._kprofile_response_data or []
  581. logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles on attempt {attempt + 1}")
  582. return profiles
  583. except asyncio.TimeoutError:
  584. logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}")
  585. if attempt < max_retries - 1:
  586. # Brief delay before retry
  587. await asyncio.sleep(0.5)
  588. finally:
  589. self._pending_kprofile_response = None
  590. logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts")
  591. return []
  592. def set_kprofile(
  593. self,
  594. filament_id: str,
  595. name: str,
  596. k_value: str,
  597. nozzle_diameter: str = "0.4",
  598. nozzle_id: str = "HS00-0.4",
  599. extruder_id: int = 0,
  600. setting_id: str | None = None,
  601. slot_id: int = 0,
  602. cali_idx: int | None = None,
  603. ) -> bool:
  604. """Set/update a K-profile on the printer.
  605. Args:
  606. filament_id: Bambu filament identifier
  607. name: Profile name
  608. k_value: Pressure advance value (e.g., "0.020000")
  609. nozzle_diameter: Nozzle diameter (e.g., "0.4")
  610. nozzle_id: Nozzle identifier (e.g., "HS00-0.4")
  611. extruder_id: Extruder ID (0 or 1 for dual nozzle)
  612. setting_id: Existing setting ID for updates, None for new
  613. slot_id: Calibration index (cali_idx) for the profile
  614. cali_idx: For H2D edits, the existing slot being edited (enables in-place edit)
  615. Returns:
  616. True if command was sent, False otherwise
  617. """
  618. if not self._client or not self.state.connected:
  619. logger.warning(f"[{self.serial_number}] Cannot set K-profile: not connected")
  620. return False
  621. self._sequence_id += 1
  622. # Detect printer type by serial number prefix
  623. # X1C/P1/A1 series (single nozzle): serial starts with "00M", "00W", "01P", "01S", "03W", etc.
  624. # H2D series (dual nozzle): serial starts with "094"
  625. is_dual_nozzle = self.serial_number.startswith("094")
  626. # For H2D edits, use empty setting_id per OrcaSlicer sniff
  627. # For new profiles, generate a setting_id
  628. import secrets
  629. if cali_idx is not None:
  630. # Edit mode - use empty setting_id per OrcaSlicer sniff
  631. setting_id = ""
  632. elif not setting_id and slot_id == 0:
  633. # New profile - generate setting_id
  634. setting_id = f"PFUS{secrets.token_hex(7)}" # 7 bytes = 14 hex chars
  635. if is_dual_nozzle:
  636. # H2D format - exact OrcaSlicer format (captured via MQTT sniffing)
  637. # For edits: include cali_idx (existing slot), slot_id=0, setting_id=""
  638. # For new profiles: no cali_idx, slot_id=0, setting_id=generated
  639. filament_entry = {
  640. "ams_id": 0,
  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": setting_id if setting_id else "",
  649. "slot_id": slot_id,
  650. "tray_id": -1,
  651. }
  652. # For edits, add cali_idx field (position matters - alphabetical order)
  653. if cali_idx is not None:
  654. # Insert cali_idx in alphabetical position (after ams_id, before extruder_id)
  655. # n_coef must be "0.000000" for H2D edits (matches OrcaSlicer sniff)
  656. filament_entry = {
  657. "ams_id": 0,
  658. "cali_idx": cali_idx,
  659. "extruder_id": extruder_id,
  660. "filament_id": filament_id,
  661. "k_value": k_value,
  662. "n_coef": "0.000000",
  663. "name": name,
  664. "nozzle_diameter": nozzle_diameter,
  665. "nozzle_id": nozzle_id,
  666. "setting_id": "",
  667. "slot_id": 0,
  668. "tray_id": -1,
  669. }
  670. command = {
  671. "print": {
  672. "command": "extrusion_cali_set",
  673. "filaments": [filament_entry],
  674. "nozzle_diameter": nozzle_diameter,
  675. "sequence_id": str(self._sequence_id),
  676. }
  677. }
  678. else:
  679. # X1C/P1/A1 format - based on actual X1C profile data:
  680. # - n_coef: "1.000000" (NOT 0.000000 like H2D)
  681. # - nozzle_id: "" (empty string, NOT the nozzle type)
  682. # - tray_id: -1 (NOT 0)
  683. filament_entry = {
  684. "ams_id": 0,
  685. "extruder_id": 0, # X1C is single nozzle
  686. "filament_id": filament_id,
  687. "k_value": k_value,
  688. "n_coef": "1.000000", # X1C uses 1.0, not 0.0
  689. "name": name,
  690. "nozzle_diameter": nozzle_diameter,
  691. "nozzle_id": "", # X1C uses empty string
  692. "setting_id": setting_id,
  693. "slot_id": slot_id,
  694. "tray_id": -1, # X1C uses -1
  695. }
  696. command = {
  697. "print": {
  698. "command": "extrusion_cali_set",
  699. "filaments": [filament_entry],
  700. "nozzle_diameter": nozzle_diameter,
  701. "sequence_id": str(self._sequence_id),
  702. }
  703. }
  704. command_json = json.dumps(command)
  705. logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0}, dual={is_dual_nozzle})")
  706. logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}")
  707. # Use QoS 1 for reliable delivery (at least once)
  708. self._client.publish(self.topic_publish, command_json, qos=1)
  709. return True
  710. def delete_kprofile(
  711. self,
  712. cali_idx: int,
  713. filament_id: str,
  714. nozzle_id: str,
  715. nozzle_diameter: str = "0.4",
  716. extruder_id: int = 0,
  717. setting_id: str | None = None,
  718. ) -> bool:
  719. """Delete a K-profile from the printer.
  720. Args:
  721. cali_idx: The calibration index (slot_id) of the profile to delete
  722. filament_id: Bambu filament identifier
  723. nozzle_id: Nozzle identifier (e.g., "HH00-0.4")
  724. nozzle_diameter: Nozzle diameter (e.g., "0.4")
  725. extruder_id: Extruder ID (0 or 1 for dual nozzle)
  726. setting_id: Unique setting identifier (for X1C series)
  727. Returns:
  728. True if command was sent, False otherwise
  729. """
  730. if not self._client or not self.state.connected:
  731. logger.warning(f"[{self.serial_number}] Cannot delete K-profile: not connected")
  732. return False
  733. self._sequence_id += 1
  734. # Detect printer type by serial number prefix
  735. # H2D series (dual nozzle): serial starts with "094"
  736. is_dual_nozzle = self.serial_number.startswith("094")
  737. if is_dual_nozzle:
  738. # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
  739. command = {
  740. "print": {
  741. "command": "extrusion_cali_del",
  742. "sequence_id": str(self._sequence_id),
  743. "extruder_id": extruder_id,
  744. "nozzle_id": nozzle_id,
  745. "filament_id": filament_id,
  746. "cali_idx": cali_idx,
  747. "nozzle_diameter": nozzle_diameter,
  748. }
  749. }
  750. else:
  751. # X1C/P1/A1 format: uses setting_id, nozzle_diameter, no extruder/nozzle_id fields
  752. command = {
  753. "print": {
  754. "command": "extrusion_cali_del",
  755. "sequence_id": str(self._sequence_id),
  756. "filament_id": filament_id,
  757. "cali_idx": cali_idx,
  758. "setting_id": setting_id,
  759. "nozzle_diameter": nozzle_diameter,
  760. }
  761. }
  762. command_json = json.dumps(command)
  763. logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, dual={is_dual_nozzle}")
  764. logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}")
  765. # Use QoS 1 for reliable delivery (at least once)
  766. self._client.publish(self.topic_publish, command_json, qos=1)
  767. return True
  768. # =========================================================================
  769. # Printer Control Commands
  770. # =========================================================================
  771. def pause_print(self) -> bool:
  772. """Pause the current print job."""
  773. if not self._client or not self.state.connected:
  774. logger.warning(f"[{self.serial_number}] Cannot pause print: not connected")
  775. return False
  776. command = {
  777. "print": {
  778. "command": "pause",
  779. "sequence_id": "0"
  780. }
  781. }
  782. self._client.publish(self.topic_publish, json.dumps(command), qos=1)
  783. logger.info(f"[{self.serial_number}] Sent pause print command")
  784. return True
  785. def resume_print(self) -> bool:
  786. """Resume a paused print job."""
  787. if not self._client or not self.state.connected:
  788. logger.warning(f"[{self.serial_number}] Cannot resume print: not connected")
  789. return False
  790. command = {
  791. "print": {
  792. "command": "resume",
  793. "sequence_id": "0"
  794. }
  795. }
  796. self._client.publish(self.topic_publish, json.dumps(command), qos=1)
  797. logger.info(f"[{self.serial_number}] Sent resume print command")
  798. return True
  799. def send_gcode(self, gcode: str) -> bool:
  800. """Send G-code command(s) to the printer.
  801. Multiple commands can be separated by newlines.
  802. Args:
  803. gcode: G-code command(s) to send
  804. Returns:
  805. True if command was sent, False otherwise
  806. """
  807. if not self._client or not self.state.connected:
  808. logger.warning(f"[{self.serial_number}] Cannot send G-code: not connected")
  809. return False
  810. self._sequence_id += 1
  811. command = {
  812. "print": {
  813. "command": "gcode_line",
  814. "param": gcode,
  815. "sequence_id": str(self._sequence_id)
  816. }
  817. }
  818. self._client.publish(self.topic_publish, json.dumps(command))
  819. logger.debug(f"[{self.serial_number}] Sent G-code: {gcode[:50]}...")
  820. return True
  821. def set_bed_temperature(self, target: int) -> bool:
  822. """Set the bed target temperature.
  823. Args:
  824. target: Target temperature in Celsius (0 to turn off)
  825. Returns:
  826. True if command was sent, False otherwise
  827. """
  828. # Use M140 for non-blocking (preferred when not waiting)
  829. # Note: P1/A1 series with newer firmware may need M190 (blocking)
  830. return self.send_gcode(f"M140 S{target}")
  831. def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool:
  832. """Set the nozzle target temperature.
  833. Args:
  834. target: Target temperature in Celsius (0 to turn off)
  835. nozzle: Nozzle index (0 for primary, 1 for secondary on H2D)
  836. Returns:
  837. True if command was sent, False otherwise
  838. """
  839. # Use M104 for non-blocking
  840. # For dual nozzle (H2D), T parameter selects the tool
  841. if nozzle == 0:
  842. return self.send_gcode(f"M104 S{target}")
  843. else:
  844. return self.send_gcode(f"M104 T{nozzle} S{target}")
  845. def set_print_speed(self, mode: int) -> bool:
  846. """Set the print speed mode.
  847. Args:
  848. mode: Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)
  849. Returns:
  850. True if command was sent, False otherwise
  851. """
  852. if not self._client or not self.state.connected:
  853. logger.warning(f"[{self.serial_number}] Cannot set print speed: not connected")
  854. return False
  855. if mode not in (1, 2, 3, 4):
  856. logger.warning(f"[{self.serial_number}] Invalid speed mode: {mode}")
  857. return False
  858. command = {
  859. "print": {
  860. "command": "print_speed",
  861. "param": str(mode),
  862. "sequence_id": "0"
  863. }
  864. }
  865. self._client.publish(self.topic_publish, json.dumps(command))
  866. logger.info(f"[{self.serial_number}] Set print speed mode to {mode}")
  867. return True
  868. def set_fan_speed(self, fan: int, speed: int) -> bool:
  869. """Set fan speed.
  870. Args:
  871. fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber)
  872. speed: Speed 0-255 (0=off, 255=full)
  873. Returns:
  874. True if command was sent, False otherwise
  875. """
  876. if fan not in (1, 2, 3):
  877. logger.warning(f"[{self.serial_number}] Invalid fan index: {fan}")
  878. return False
  879. speed = max(0, min(255, speed)) # Clamp to 0-255
  880. return self.send_gcode(f"M106 P{fan} S{speed}")
  881. def set_part_fan(self, speed: int) -> bool:
  882. """Set part cooling fan speed (0-255)."""
  883. return self.set_fan_speed(1, speed)
  884. def set_aux_fan(self, speed: int) -> bool:
  885. """Set auxiliary fan speed (0-255)."""
  886. return self.set_fan_speed(2, speed)
  887. def set_chamber_fan(self, speed: int) -> bool:
  888. """Set chamber fan speed (0-255)."""
  889. return self.set_fan_speed(3, speed)
  890. def set_chamber_light(self, on: bool) -> bool:
  891. """Turn chamber light on or off.
  892. Args:
  893. on: True to turn on, False to turn off
  894. Returns:
  895. True if command was sent, False otherwise
  896. """
  897. if not self._client or not self.state.connected:
  898. logger.warning(f"[{self.serial_number}] Cannot set chamber light: not connected")
  899. return False
  900. command = {
  901. "system": {
  902. "command": "ledctrl",
  903. "led_node": "chamber_light",
  904. "led_mode": "on" if on else "off",
  905. "led_on_time": 500,
  906. "led_off_time": 500,
  907. "loop_times": 0,
  908. "interval_time": 0,
  909. "sequence_id": "0"
  910. }
  911. }
  912. self._client.publish(self.topic_publish, json.dumps(command))
  913. logger.info(f"[{self.serial_number}] Set chamber light {'on' if on else 'off'}")
  914. return True
  915. def home_axes(self, axes: str = "XYZ") -> bool:
  916. """Home the specified axes.
  917. Args:
  918. axes: Axes to home (e.g., "XYZ", "X", "XY", "Z")
  919. Returns:
  920. True if command was sent, False otherwise
  921. """
  922. # G28 homes all axes, G28 X Y Z homes specific axes
  923. axes_param = " ".join(axes.upper())
  924. return self.send_gcode(f"G28 {axes_param}")
  925. def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool:
  926. """Move an axis by a relative distance.
  927. Args:
  928. axis: Axis to move ("X", "Y", or "Z")
  929. distance: Distance to move in mm (positive or negative)
  930. speed: Movement speed in mm/min
  931. Returns:
  932. True if command was sent, False otherwise
  933. """
  934. axis = axis.upper()
  935. if axis not in ("X", "Y", "Z"):
  936. logger.warning(f"[{self.serial_number}] Invalid axis: {axis}")
  937. return False
  938. # G91 = relative mode, G0 = rapid move, G90 = back to absolute
  939. gcode = f"G91\nG0 {axis}{distance:.2f} F{speed}\nG90"
  940. return self.send_gcode(gcode)
  941. def disable_motors(self) -> bool:
  942. """Disable all stepper motors.
  943. Warning: This will cause the printer to lose its position.
  944. A homing operation will be required before printing.
  945. Returns:
  946. True if command was sent, False otherwise
  947. """
  948. return self.send_gcode("M18")
  949. def enable_motors(self) -> bool:
  950. """Enable all stepper motors.
  951. Returns:
  952. True if command was sent, False otherwise
  953. """
  954. return self.send_gcode("M17")
  955. def ams_load_filament(self, tray_id: int) -> bool:
  956. """Load filament from a specific AMS tray.
  957. Args:
  958. tray_id: Tray ID (0-15 for AMS slots, or 254 for external spool)
  959. Returns:
  960. True if command was sent, False otherwise
  961. """
  962. if not self._client or not self.state.connected:
  963. logger.warning(f"[{self.serial_number}] Cannot load filament: not connected")
  964. return False
  965. command = {
  966. "print": {
  967. "command": "ams_change_filament",
  968. "target": tray_id,
  969. "sequence_id": "0"
  970. }
  971. }
  972. self._client.publish(self.topic_publish, json.dumps(command))
  973. logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id}")
  974. return True
  975. def ams_unload_filament(self) -> bool:
  976. """Unload the currently loaded filament.
  977. Returns:
  978. True if command was sent, False otherwise
  979. """
  980. if not self._client or not self.state.connected:
  981. logger.warning(f"[{self.serial_number}] Cannot unload filament: not connected")
  982. return False
  983. command = {
  984. "print": {
  985. "command": "ams_change_filament",
  986. "target": 255, # 255 = unload
  987. "sequence_id": "0"
  988. }
  989. }
  990. self._client.publish(self.topic_publish, json.dumps(command))
  991. logger.info(f"[{self.serial_number}] Unloading filament")
  992. return True
  993. def ams_control(self, action: str) -> bool:
  994. """Control AMS operations.
  995. Args:
  996. action: "resume", "reset", or "pause"
  997. Returns:
  998. True if command was sent, False otherwise
  999. """
  1000. if not self._client or not self.state.connected:
  1001. logger.warning(f"[{self.serial_number}] Cannot control AMS: not connected")
  1002. return False
  1003. if action not in ("resume", "reset", "pause"):
  1004. logger.warning(f"[{self.serial_number}] Invalid AMS action: {action}")
  1005. return False
  1006. command = {
  1007. "print": {
  1008. "command": "ams_control",
  1009. "param": action,
  1010. "sequence_id": "0"
  1011. }
  1012. }
  1013. self._client.publish(self.topic_publish, json.dumps(command))
  1014. logger.info(f"[{self.serial_number}] AMS control: {action}")
  1015. return True