bambu_mqtt.py 49 KB

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