bambu_mqtt.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import json
  2. import ssl
  3. import asyncio
  4. from typing import Callable
  5. from dataclasses import dataclass, field
  6. import paho.mqtt.client as mqtt
  7. @dataclass
  8. class PrinterState:
  9. connected: bool = False
  10. state: str = "unknown"
  11. current_print: str | None = None
  12. subtask_name: str | None = None
  13. progress: float = 0.0
  14. remaining_time: int = 0
  15. layer_num: int = 0
  16. total_layers: int = 0
  17. temperatures: dict = field(default_factory=dict)
  18. raw_data: dict = field(default_factory=dict)
  19. gcode_file: str | None = None
  20. subtask_id: str | None = None
  21. class BambuMQTTClient:
  22. """MQTT client for Bambu Lab printer communication."""
  23. MQTT_PORT = 8883
  24. def __init__(
  25. self,
  26. ip_address: str,
  27. serial_number: str,
  28. access_code: str,
  29. on_state_change: Callable[[PrinterState], None] | None = None,
  30. on_print_start: Callable[[dict], None] | None = None,
  31. on_print_complete: Callable[[dict], None] | None = None,
  32. ):
  33. self.ip_address = ip_address
  34. self.serial_number = serial_number
  35. self.access_code = access_code
  36. self.on_state_change = on_state_change
  37. self.on_print_start = on_print_start
  38. self.on_print_complete = on_print_complete
  39. self.state = PrinterState()
  40. self._client: mqtt.Client | None = None
  41. self._loop: asyncio.AbstractEventLoop | None = None
  42. self._previous_gcode_state: str | None = None
  43. self._previous_gcode_file: str | None = None
  44. @property
  45. def topic_subscribe(self) -> str:
  46. return f"device/{self.serial_number}/report"
  47. @property
  48. def topic_publish(self) -> str:
  49. return f"device/{self.serial_number}/request"
  50. def _on_connect(self, client, userdata, flags, rc, properties=None):
  51. if rc == 0:
  52. self.state.connected = True
  53. client.subscribe(self.topic_subscribe)
  54. # Request full status update
  55. self._request_push_all()
  56. else:
  57. self.state.connected = False
  58. def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
  59. self.state.connected = False
  60. if self.on_state_change:
  61. self.on_state_change(self.state)
  62. def _on_message(self, client, userdata, msg):
  63. try:
  64. payload = json.loads(msg.payload.decode())
  65. self._process_message(payload)
  66. except json.JSONDecodeError:
  67. pass
  68. def _process_message(self, payload: dict):
  69. """Process incoming MQTT message from printer."""
  70. if "print" in payload:
  71. print_data = payload["print"]
  72. self._update_state(print_data)
  73. def _update_state(self, data: dict):
  74. """Update printer state from message data."""
  75. previous_state = self.state.state
  76. # Update state fields
  77. if "gcode_state" in data:
  78. self.state.state = data["gcode_state"]
  79. if "gcode_file" in data:
  80. self.state.gcode_file = data["gcode_file"]
  81. self.state.current_print = data["gcode_file"]
  82. if "subtask_name" in data:
  83. self.state.subtask_name = data["subtask_name"]
  84. # Prefer subtask_name as current_print if available
  85. if data["subtask_name"]:
  86. self.state.current_print = data["subtask_name"]
  87. if "subtask_id" in data:
  88. self.state.subtask_id = data["subtask_id"]
  89. if "mc_percent" in data:
  90. self.state.progress = float(data["mc_percent"])
  91. if "mc_remaining_time" in data:
  92. self.state.remaining_time = int(data["mc_remaining_time"])
  93. if "layer_num" in data:
  94. self.state.layer_num = int(data["layer_num"])
  95. if "total_layer_num" in data:
  96. self.state.total_layers = int(data["total_layer_num"])
  97. # Temperature data
  98. temps = {}
  99. if "bed_temper" in data:
  100. temps["bed"] = float(data["bed_temper"])
  101. if "bed_target_temper" in data:
  102. temps["bed_target"] = float(data["bed_target_temper"])
  103. if "nozzle_temper" in data:
  104. temps["nozzle"] = float(data["nozzle_temper"])
  105. if "nozzle_target_temper" in data:
  106. temps["nozzle_target"] = float(data["nozzle_target_temper"])
  107. # Second nozzle for dual-extruder printers (H2 series)
  108. if "nozzle_temper_2" in data:
  109. temps["nozzle_2"] = float(data["nozzle_temper_2"])
  110. if "nozzle_target_temper_2" in data:
  111. temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
  112. if "chamber_temper" in data:
  113. temps["chamber"] = float(data["chamber_temper"])
  114. if temps:
  115. self.state.temperatures = temps
  116. self.state.raw_data = data
  117. # Detect print start (state changes TO RUNNING with a file)
  118. current_file = self.state.gcode_file or self.state.current_print
  119. is_new_print = (
  120. self.state.state == "RUNNING"
  121. and self._previous_gcode_state != "RUNNING"
  122. and current_file
  123. )
  124. # Also detect if file changed while running (new print started)
  125. is_file_change = (
  126. self.state.state == "RUNNING"
  127. and current_file
  128. and current_file != self._previous_gcode_file
  129. and self._previous_gcode_file is not None
  130. )
  131. if (is_new_print or is_file_change) and self.on_print_start:
  132. self.on_print_start({
  133. "filename": current_file,
  134. "subtask_name": self.state.subtask_name,
  135. "raw_data": data,
  136. })
  137. # Detect print completion
  138. if (
  139. self._previous_gcode_state == "RUNNING"
  140. and self.state.state in ("FINISH", "FAILED")
  141. and self.on_print_complete
  142. ):
  143. self.on_print_complete({
  144. "status": "completed" if self.state.state == "FINISH" else "failed",
  145. "filename": self._previous_gcode_file or current_file,
  146. "raw_data": data,
  147. })
  148. self._previous_gcode_state = self.state.state
  149. if current_file:
  150. self._previous_gcode_file = current_file
  151. if self.on_state_change:
  152. self.on_state_change(self.state)
  153. def _request_push_all(self):
  154. """Request full status update from printer."""
  155. if self._client:
  156. message = {"pushing": {"command": "pushall"}}
  157. self._client.publish(self.topic_publish, json.dumps(message))
  158. def connect(self):
  159. """Connect to the printer MQTT broker."""
  160. self._client = mqtt.Client(
  161. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  162. client_id=f"bambutrack_{self.serial_number}",
  163. protocol=mqtt.MQTTv311,
  164. )
  165. self._client.username_pw_set("bblp", self.access_code)
  166. self._client.on_connect = self._on_connect
  167. self._client.on_disconnect = self._on_disconnect
  168. self._client.on_message = self._on_message
  169. # TLS setup - Bambu uses self-signed certs
  170. ssl_context = ssl.create_default_context()
  171. ssl_context.check_hostname = False
  172. ssl_context.verify_mode = ssl.CERT_NONE
  173. self._client.tls_set_context(ssl_context)
  174. self._client.connect_async(self.ip_address, self.MQTT_PORT)
  175. self._client.loop_start()
  176. def start_print(self, filename: str, plate_id: int = 1):
  177. """Start a print job on the printer."""
  178. if self._client and self.state.connected:
  179. # Bambu print command format
  180. command = {
  181. "print": {
  182. "command": "project_file",
  183. "param": f"Metadata/plate_{plate_id}.gcode",
  184. "subtask_name": filename,
  185. "url": f"ftp://{filename}",
  186. "bed_type": "auto",
  187. "timelapse": False,
  188. "bed_leveling": True,
  189. "flow_cali": True,
  190. "vibration_cali": True,
  191. "layer_inspect": False,
  192. "use_ams": True,
  193. }
  194. }
  195. self._client.publish(self.topic_publish, json.dumps(command))
  196. return True
  197. return False
  198. def disconnect(self):
  199. """Disconnect from the printer."""
  200. if self._client:
  201. self._client.loop_stop()
  202. self._client.disconnect()
  203. self._client = None
  204. self.state.connected = False
  205. def send_command(self, command: dict):
  206. """Send a command to the printer."""
  207. if self._client and self.state.connected:
  208. self._client.publish(self.topic_publish, json.dumps(command))