mqtt_server.py 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231
  1. """MQTT broker for virtual printer.
  2. Implements an MQTT broker that accepts connections from slicers,
  3. authenticates with the configured access code, and logs print commands.
  4. """
  5. import asyncio
  6. import copy
  7. import hmac
  8. import json
  9. import logging
  10. import ssl
  11. from collections.abc import Callable
  12. from pathlib import Path
  13. from typing import TYPE_CHECKING
  14. if TYPE_CHECKING:
  15. from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
  16. logger = logging.getLogger(__name__)
  17. # Default MQTT port for Bambu printers (MQTT over TLS)
  18. MQTT_PORT = 8883
  19. # Per-IP MQTT auth rate-limit. 5 failures within 60 s blocks further attempts
  20. # for the remainder of the window. Bambu printers themselves don't rate-limit,
  21. # but they're not exposed past the LAN edge; Bambuddy's VPs sometimes are
  22. # (Tailscale, port-forwarded), so an 8-char access code without any
  23. # brute-force friction is too weak. The window auto-recovers — no manual
  24. # unblock — so a legitimate user who fat-fingered their access code 5 times
  25. # only waits up to 60 s.
  26. _AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
  27. _AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
  28. # Pending-request map bound. Each entry maps a slicer command's
  29. # sequence_id to its originating client_id so the bridge response can be
  30. # routed back to just that client. Bounded so a slicer that issues
  31. # commands without ever consuming responses can't leak memory.
  32. _PENDING_REQUEST_MAX_ENTRIES = 256
  33. # Model code → product_name for version response (must match what slicer expects)
  34. MODEL_PRODUCT_NAMES = {
  35. "BL-P001": "X1 Carbon",
  36. "BL-P002": "X1",
  37. "C13": "X1E",
  38. "N6": "X2D",
  39. "C11": "P1P",
  40. "C12": "P1S",
  41. "N7": "P2S",
  42. "N2S": "A1",
  43. "N1": "A1 mini",
  44. "O1D": "H2D",
  45. "O1C": "H2C",
  46. "O1C2": "H2C",
  47. "O1S": "H2S",
  48. }
  49. class VirtualPrinterMQTTServer:
  50. """MQTT broker that accepts connections from slicers.
  51. This is a minimal MQTT broker implementation that:
  52. - Accepts TLS connections on port 8883
  53. - Authenticates with username 'bblp' and the configured access code
  54. - Receives print commands on device/{serial}/request
  55. - Can publish status on device/{serial}/report
  56. """
  57. def __init__(
  58. self,
  59. serial: str,
  60. access_code: str,
  61. cert_path: Path,
  62. key_path: Path,
  63. port: int = MQTT_PORT,
  64. on_print_command: Callable[[str, dict], None] | None = None,
  65. ):
  66. """Initialize the MQTT server.
  67. Args:
  68. serial: Virtual printer serial number
  69. access_code: Password for authentication
  70. cert_path: Path to TLS certificate
  71. key_path: Path to TLS private key
  72. port: Port to listen on (default 8883)
  73. on_print_command: Callback when print command received (filename, data)
  74. """
  75. self.serial = serial
  76. self.access_code = access_code
  77. self.cert_path = cert_path
  78. self.key_path = key_path
  79. self.port = port
  80. self.on_print_command = on_print_command
  81. self._running = False
  82. self._broker = None
  83. self._broker_task = None
  84. async def start(self) -> None:
  85. """Start the MQTT broker."""
  86. if self._running:
  87. return
  88. # Try to import amqtt
  89. try:
  90. from amqtt.broker import Broker
  91. except ImportError:
  92. logger.error("amqtt not installed. Run: pip install amqtt")
  93. return
  94. logger.info("Starting virtual printer MQTT broker on port %s", self.port)
  95. # Build broker configuration
  96. config = {
  97. "listeners": {
  98. "default": {
  99. "type": "tcp",
  100. "bind": f"0.0.0.0:{self.port}",
  101. "ssl": "on",
  102. "certfile": str(self.cert_path),
  103. "keyfile": str(self.key_path),
  104. },
  105. },
  106. "auth": {
  107. "allow-anonymous": False,
  108. "plugins": ["auth_custom"],
  109. },
  110. "topic-check": {
  111. "enabled": False, # Allow any topic
  112. },
  113. }
  114. try:
  115. self._running = True
  116. # Create and start broker
  117. self._broker = Broker(config)
  118. # Register custom auth plugin
  119. self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate
  120. # Start the broker
  121. await self._broker.start()
  122. logger.info("MQTT broker started on port %s", self.port)
  123. # Keep running
  124. while self._running:
  125. await asyncio.sleep(1)
  126. except OSError as e:
  127. if e.errno == 98: # Address already in use
  128. logger.error("MQTT port %s is already in use", self.port)
  129. else:
  130. logger.error("MQTT broker error: %s", e)
  131. except asyncio.CancelledError:
  132. logger.debug("MQTT broker task cancelled")
  133. except Exception as e:
  134. logger.error("MQTT broker error: %s", e)
  135. finally:
  136. await self.stop()
  137. async def _authenticate(self, session) -> bool:
  138. """Authenticate MQTT connection.
  139. Args:
  140. session: MQTT session with username/password
  141. Returns:
  142. True if authentication successful
  143. """
  144. username = getattr(session, "username", None)
  145. password = getattr(session, "password", None)
  146. # Bambu slicers use 'bblp' as username and access code as password
  147. if username == "bblp" and password == self.access_code:
  148. logger.debug("MQTT client authenticated from %s", session.remote_address)
  149. return True
  150. logger.warning("MQTT auth failed for user '%s' from %s", username, session.remote_address)
  151. return False
  152. async def stop(self) -> None:
  153. """Stop the MQTT broker."""
  154. logger.info("Stopping MQTT broker")
  155. self._running = False
  156. if self._broker:
  157. try:
  158. await self._broker.shutdown()
  159. except OSError as e:
  160. logger.debug("Error shutting down MQTT broker: %s", e)
  161. self._broker = None
  162. class SimpleMQTTServer:
  163. """Simplified MQTT server using raw sockets.
  164. This is a fallback implementation that handles basic MQTT protocol
  165. without requiring the amqtt library. It's less feature-complete but
  166. more lightweight.
  167. """
  168. def __init__(
  169. self,
  170. serial: str,
  171. access_code: str,
  172. cert_path: Path,
  173. key_path: Path,
  174. port: int = MQTT_PORT,
  175. on_print_command: Callable[[str, dict], None] | None = None,
  176. model: str = "",
  177. bind_address: str = "0.0.0.0", # nosec B104
  178. vp_name: str = "",
  179. ):
  180. self.serial = serial
  181. self.access_code = access_code
  182. self.model = model
  183. self.cert_path = cert_path
  184. self.key_path = key_path
  185. self.port = port
  186. self.on_print_command = on_print_command
  187. self.bind_address = bind_address
  188. self.vp_name = vp_name
  189. self._log_prefix = f"[{vp_name}] " if vp_name else ""
  190. self._running = False
  191. # Set after the socket is bound — see ftp_server.py for rationale.
  192. self.ready = asyncio.Event()
  193. self._server = None
  194. self._clients: dict[str, asyncio.StreamWriter] = {}
  195. # Per-client "effective serial" — the serial the slicer actually uses in
  196. # device/{serial}/report|request topics. Populated from the first
  197. # SUBSCRIBE/PUBLISH we see on a connection. This lets the VP respond on
  198. # the topic the slicer is listening on even when it disagrees with
  199. # self.serial (e.g. a stale Orca config that was bound to an older VP
  200. # serial, or a printer entry that was re-pointed at the VP IP without
  201. # updating the serial).
  202. self._client_serials: dict[str, str] = {}
  203. self._status_push_task: asyncio.Task | None = None
  204. self._sequence_id = 0
  205. # Dynamic state for status reports
  206. self._gcode_state = "IDLE"
  207. self._current_file = ""
  208. self._prepare_percent = "0"
  209. # MQTT bridge for non-proxy modes — set by VirtualPrinterInstance after start().
  210. # When the bridge is_active, real printer pushes are fanned out to slicers and
  211. # the synthetic 1s push is suspended. When the target printer goes offline the
  212. # synthetic fallback resumes automatically.
  213. self._bridge: MQTTBridge | None = None
  214. # Per-source-IP failed-auth tracker for rate-limiting / lockout.
  215. # Maps IP → list[monotonic timestamp] of recent failures within the
  216. # window. Pruned on every check so it doesn't grow unbounded.
  217. self._auth_failures: dict[str, list[float]] = {}
  218. # Maps sequence_id → originating client_id for slicer-initiated
  219. # commands forwarded to the real printer. Used in
  220. # ``push_raw_to_clients`` to route the printer's response only
  221. # back to the requesting slicer instead of fanning out to all
  222. # connected clients (which leaks slicer A's responses to slicer
  223. # B in multi-slicer setups). FIFO-bounded; if a response never
  224. # arrives the entry ages out instead of leaking.
  225. self._pending_requests: dict[str, str] = {}
  226. async def start(self) -> None:
  227. """Start the MQTT server."""
  228. if self._running:
  229. return
  230. logger.info("Starting simple MQTT server on port %s", self.port)
  231. # Create SSL context with Bambu-compatible settings
  232. ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  233. ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
  234. # Match Bambu printer behavior - accept any client
  235. ssl_context.verify_mode = ssl.CERT_NONE
  236. # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
  237. ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
  238. # Disable hostname checking
  239. ssl_context.check_hostname = False
  240. # Log certificate info
  241. import subprocess
  242. try:
  243. result = subprocess.run(
  244. ["openssl", "x509", "-in", str(self.cert_path), "-noout", "-subject", "-issuer"],
  245. capture_output=True,
  246. text=True,
  247. timeout=5,
  248. )
  249. logger.info("MQTT SSL cert info: %s", result.stdout.strip())
  250. except (OSError, subprocess.SubprocessError):
  251. pass # Certificate info is for debug logging only; not critical
  252. logger.info("MQTT SSL context: TLS 1.2+, cert=%s", self.cert_path)
  253. try:
  254. self._running = True
  255. # Wrapper to log ALL connection attempts including SSL errors
  256. async def connection_handler(reader, writer):
  257. try:
  258. addr = writer.get_extra_info("peername")
  259. ssl_obj = writer.get_extra_info("ssl_object")
  260. if ssl_obj:
  261. logger.info(
  262. f"{self._log_prefix}MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
  263. )
  264. else:
  265. logger.info("%sMQTT connection from %s (no TLS?)", self._log_prefix, addr)
  266. await self._handle_client(reader, writer)
  267. except ssl.SSLError as e:
  268. logger.error("MQTT SSL error: %s", e)
  269. except Exception as e:
  270. logger.error("MQTT connection handler error: %s", e)
  271. self._server = await asyncio.start_server(
  272. connection_handler,
  273. self.bind_address,
  274. self.port,
  275. ssl=ssl_context,
  276. )
  277. self.ready.set()
  278. logger.info("Simple MQTT server listening on port %s", self.port)
  279. # Start periodic status push task
  280. self._status_push_task = asyncio.create_task(self._periodic_status_push())
  281. async with self._server:
  282. await self._server.serve_forever()
  283. except OSError as e:
  284. if e.errno == 98: # Address already in use
  285. logger.error("MQTT port %s is already in use", self.port)
  286. else:
  287. logger.error("MQTT server error: %s", e)
  288. except asyncio.CancelledError:
  289. logger.debug("MQTT server task cancelled")
  290. except Exception as e:
  291. logger.error("MQTT server error: %s", e)
  292. finally:
  293. await self.stop()
  294. async def stop(self) -> None:
  295. """Stop the MQTT server."""
  296. logger.info("Stopping simple MQTT server")
  297. self._running = False
  298. self.ready.clear()
  299. # Stop periodic status push
  300. if self._status_push_task:
  301. self._status_push_task.cancel()
  302. try:
  303. await self._status_push_task
  304. except asyncio.CancelledError:
  305. pass # Expected when stopping the periodic status push task
  306. self._status_push_task = None
  307. # Close all client connections (iterate over copy to avoid modification during iteration)
  308. for _client_id, writer in list(self._clients.items()):
  309. try:
  310. writer.close()
  311. await writer.wait_closed()
  312. except OSError:
  313. pass # Best-effort client connection cleanup; client may have disconnected
  314. self._clients.clear()
  315. self._client_serials.clear()
  316. if self._server:
  317. try:
  318. self._server.close()
  319. await self._server.wait_closed()
  320. except OSError:
  321. pass # Best-effort server shutdown; port may already be released
  322. self._server = None
  323. @staticmethod
  324. def _extract_serial_from_topic(topic: str) -> str | None:
  325. """Pull the serial out of a `device/{serial}/report|request` topic.
  326. Returns None if the topic doesn't match that shape — callers fall back
  327. to self.serial in that case.
  328. """
  329. if not topic.startswith("device/"):
  330. return None
  331. rest = topic[len("device/") :]
  332. # Expect "{serial}/report" or "{serial}/request" (possibly with suffixes).
  333. slash = rest.find("/")
  334. if slash <= 0:
  335. return None
  336. return rest[:slash]
  337. def set_bridge(self, bridge: "MQTTBridge | None") -> None:
  338. """Attach (or detach) the MQTT bridge that mirrors the target printer."""
  339. self._bridge = bridge
  340. async def _periodic_status_push(self) -> None:
  341. """Send periodic status updates to all connected clients (1 Hz, exact pre-bridge behaviour)."""
  342. logger.info("Starting periodic status push task")
  343. while self._running:
  344. try:
  345. await asyncio.sleep(1) # Push every 1 second like real printers
  346. disconnected = []
  347. for client_id, writer in list(self._clients.items()):
  348. try:
  349. if writer.is_closing():
  350. disconnected.append(client_id)
  351. continue
  352. serial = self._client_serials.get(client_id, self.serial)
  353. await self._send_status_report(writer, serial=serial)
  354. except OSError as e:
  355. logger.debug("Failed to push status to %s: %s", client_id, e)
  356. disconnected.append(client_id)
  357. # Remove disconnected clients
  358. for client_id in disconnected:
  359. self._clients.pop(client_id, None)
  360. self._client_serials.pop(client_id, None)
  361. except asyncio.CancelledError:
  362. break
  363. except Exception as e:
  364. logger.error("Periodic status push error: %s", e)
  365. logger.info("Periodic status push task stopped")
  366. async def push_raw_to_clients(self, topic: str, payload: bytes) -> None:
  367. """Publish a pre-serialized MQTT payload on `topic` to connected slicers.
  368. Called by MQTTBridge from the asyncio loop (scheduled via
  369. run_coroutine_threadsafe from paho's network thread).
  370. Routes the response only back to the originating slicer if the
  371. payload's sequence_id was previously recorded via
  372. ``_record_pending_request``. Falls back to fan-out for
  373. printer-initiated pushes (push_status etc.) and for sequence_ids
  374. we never saw (covers a slicer that subscribes mid-flight to a
  375. topic for which an earlier request is still in flight).
  376. """
  377. topic_bytes = topic.encode("utf-8")
  378. # MQTT remaining-length: 2-byte topic length prefix + topic + message body.
  379. remaining = 2 + len(topic_bytes) + len(payload)
  380. packet = bytearray([0x30]) # PUBLISH, QoS 0
  381. while True:
  382. byte = remaining % 128
  383. remaining //= 128
  384. if remaining > 0:
  385. byte |= 0x80
  386. packet.append(byte)
  387. if remaining == 0:
  388. break
  389. packet.extend([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
  390. packet.extend(topic_bytes)
  391. packet.extend(payload)
  392. frame = bytes(packet)
  393. target_client_id = self._lookup_pending_request_client(payload)
  394. disconnected = []
  395. for client_id, writer in list(self._clients.items()):
  396. if target_client_id is not None and client_id != target_client_id:
  397. continue
  398. try:
  399. if writer.is_closing():
  400. disconnected.append(client_id)
  401. continue
  402. writer.write(frame)
  403. try:
  404. await asyncio.wait_for(writer.drain(), timeout=5)
  405. except TimeoutError:
  406. logger.debug("MQTT drain timeout pushing bridge frame to %s", client_id)
  407. except OSError as e:
  408. logger.debug("Failed to push bridge frame to %s: %s", client_id, e)
  409. disconnected.append(client_id)
  410. for client_id in disconnected:
  411. self._clients.pop(client_id, None)
  412. self._client_serials.pop(client_id, None)
  413. async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  414. """Handle an MQTT client connection."""
  415. addr = writer.get_extra_info("peername")
  416. client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
  417. logger.info("%sMQTT client connected: %s", self._log_prefix, client_id)
  418. authenticated = False
  419. # Per-packet read timeout. Before CONNECT we default to 60 s so a
  420. # client that opens TCP but never sends anything still gets reaped;
  421. # after CONNECT the value is updated to 1.5× the keepalive the
  422. # client negotiated (MQTT spec §4.4). ``None`` means no timeout,
  423. # which is what spec §3.1.2.10 mandates for keep_alive == 0.
  424. read_timeout: float | None = 60.0
  425. try:
  426. while self._running:
  427. # Read MQTT fixed header
  428. try:
  429. header = await asyncio.wait_for(reader.read(1), timeout=read_timeout)
  430. except TimeoutError:
  431. break
  432. if not header:
  433. break
  434. packet_type = (header[0] & 0xF0) >> 4
  435. # Read remaining length
  436. remaining_length = await self._read_remaining_length(reader)
  437. if remaining_length is None:
  438. break
  439. # Read payload
  440. payload = await reader.read(remaining_length) if remaining_length > 0 else b""
  441. # Handle packet types
  442. if packet_type == 1: # CONNECT
  443. source_ip = addr[0] if addr else "unknown"
  444. if self._is_auth_rate_limited(source_ip):
  445. logger.warning(
  446. "%sMQTT auth rate-limited from %s (>=%d failures in %ds)",
  447. self._log_prefix,
  448. source_ip,
  449. _AUTH_RATE_LIMIT_MAX_ATTEMPTS,
  450. int(_AUTH_RATE_LIMIT_WINDOW_SECONDS),
  451. )
  452. writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized
  453. await writer.drain()
  454. break
  455. authenticated, keep_alive = await self._handle_connect(payload, writer)
  456. if not authenticated:
  457. self._record_auth_failure(source_ip)
  458. break
  459. self._clear_auth_failures(source_ip)
  460. # Honour the client's negotiated keepalive (#1548). Before
  461. # this fix, the hardcoded 60 s above would close
  462. # OrcaSlicer's idle connection at the keepalive boundary
  463. # instead of waiting 1.5× as the spec requires — Orca
  464. # sends PINGREQ within its own keepalive interval but
  465. # we'd already have closed the socket.
  466. read_timeout = keep_alive * 1.5 if keep_alive > 0 else None
  467. # Register client for periodic status pushes; start with
  468. # self.serial as the fallback until we learn the slicer's
  469. # preferred serial from the first SUBSCRIBE/PUBLISH.
  470. self._clients[client_id] = writer
  471. self._client_serials[client_id] = self.serial
  472. elif packet_type == 3: # PUBLISH
  473. if authenticated:
  474. await self._handle_publish(header[0], payload, writer, client_id)
  475. elif packet_type == 8: # SUBSCRIBE
  476. if authenticated:
  477. await self._handle_subscribe(payload, writer, client_id)
  478. elif packet_type == 12: # PINGREQ
  479. # Send PINGRESP
  480. writer.write(bytes([0xD0, 0x00]))
  481. await writer.drain()
  482. elif packet_type == 14: # DISCONNECT
  483. break
  484. except asyncio.CancelledError:
  485. pass # Expected when server is shutting down and cancels client tasks
  486. except Exception as e:
  487. # Outer handler — inner handlers already absorb expected parser
  488. # / IO failures at debug. Anything reaching here is unexpected
  489. # and would otherwise silently drop the slicer connection with
  490. # no actionable signal in production logs (defaults are INFO+).
  491. logger.warning("%sMQTT client session error from %s: %s", self._log_prefix, client_id, e)
  492. finally:
  493. logger.debug("MQTT client disconnected: %s", client_id)
  494. self._clients.pop(client_id, None)
  495. self._client_serials.pop(client_id, None)
  496. try:
  497. writer.close()
  498. await writer.wait_closed()
  499. except OSError:
  500. pass # Best-effort socket cleanup on client disconnect
  501. async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:
  502. """Read MQTT remaining length (variable byte integer)."""
  503. multiplier = 1
  504. value = 0
  505. for _ in range(4):
  506. try:
  507. byte = await reader.read(1)
  508. if not byte:
  509. return None
  510. encoded = byte[0]
  511. value += (encoded & 127) * multiplier
  512. if (encoded & 128) == 0:
  513. return value
  514. multiplier *= 128
  515. except OSError:
  516. return None
  517. return None
  518. def _record_pending_request(self, data: dict, client_id: str) -> None:
  519. """Stash sequence_id → client_id for any nested block with a sequence_id.
  520. Slicer commands typically wrap their seq id in ``{"print": {...}}`` or
  521. ``{"info": {...}}`` / ``{"system": {...}}`` etc. Walks top-level dict
  522. values once to find the seq id; if absent (some commands omit it) we
  523. skip — the response will fall through to broadcast which is fine for
  524. unsolicited pushes.
  525. """
  526. for block in data.values():
  527. if isinstance(block, dict):
  528. seq = block.get("sequence_id")
  529. if seq is not None:
  530. key = str(seq)
  531. # Evict oldest entry when over the cap. Python dicts
  532. # preserve insertion order so iter(self._pending_requests)
  533. # yields the oldest key first.
  534. while len(self._pending_requests) >= _PENDING_REQUEST_MAX_ENTRIES:
  535. oldest = next(iter(self._pending_requests))
  536. self._pending_requests.pop(oldest, None)
  537. self._pending_requests[key] = client_id
  538. return
  539. def _lookup_pending_request_client(self, payload: bytes) -> str | None:
  540. """Parse a bridge-forwarded MQTT payload and return the originating
  541. client_id if its sequence_id was recorded.
  542. Returns ``None`` for printer-initiated pushes (no recorded seq id) so
  543. push_raw_to_clients falls back to broadcast — required for push_status
  544. and the other unsolicited pushes that every connected slicer expects.
  545. """
  546. try:
  547. parsed = json.loads(payload)
  548. except (ValueError, TypeError):
  549. return None
  550. if not isinstance(parsed, dict):
  551. return None
  552. for block in parsed.values():
  553. if isinstance(block, dict):
  554. seq = block.get("sequence_id")
  555. if seq is not None:
  556. return self._pending_requests.pop(str(seq), None)
  557. return None
  558. def _is_auth_rate_limited(self, source_ip: str) -> bool:
  559. """Return True if ``source_ip`` has hit the per-IP failure cap.
  560. Prunes timestamps older than the window so the dict doesn't grow
  561. unbounded. Uses ``time.monotonic()`` for a wall-clock-jump-immune
  562. clock that's safe to call from any context (sync or async).
  563. """
  564. import time as _time
  565. now = _time.monotonic()
  566. window_start = now - _AUTH_RATE_LIMIT_WINDOW_SECONDS
  567. recent = [t for t in self._auth_failures.get(source_ip, []) if t >= window_start]
  568. if recent:
  569. self._auth_failures[source_ip] = recent
  570. else:
  571. self._auth_failures.pop(source_ip, None)
  572. return len(recent) >= _AUTH_RATE_LIMIT_MAX_ATTEMPTS
  573. def _record_auth_failure(self, source_ip: str) -> None:
  574. """Append a timestamp for ``source_ip``'s failed auth attempt."""
  575. import time as _time
  576. now = _time.monotonic()
  577. self._auth_failures.setdefault(source_ip, []).append(now)
  578. def _clear_auth_failures(self, source_ip: str) -> None:
  579. """Reset ``source_ip``'s failure history after a successful auth."""
  580. self._auth_failures.pop(source_ip, None)
  581. async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> tuple[bool, int]:
  582. """Handle MQTT CONNECT packet.
  583. Returns ``(authenticated, keep_alive_seconds)`` — the second element
  584. is the value the client advertised in CONNECT, so the caller's
  585. read-loop can honour it instead of the hardcoded default. ``0``
  586. means the client opted out of keepalive (#1548).
  587. """
  588. try:
  589. # Parse CONNECT packet
  590. # Skip protocol name length and name
  591. idx = 0
  592. proto_len = (payload[idx] << 8) | payload[idx + 1]
  593. idx += 2 + proto_len
  594. # Skip protocol level and connect flags
  595. # connect_flags = payload[idx + 1]
  596. idx += 2
  597. # Keepalive (2-byte big-endian, seconds). Honoured by the read
  598. # loop in `_handle_client` per MQTT spec §3.1.2.10 / §4.4 —
  599. # before #1548 we ignored this and used a hardcoded 60 s, which
  600. # closed OrcaSlicer's idle connection at exactly the negotiated
  601. # keepalive boundary instead of the spec-mandated 1.5×.
  602. keep_alive = (payload[idx] << 8) | payload[idx + 1]
  603. idx += 2
  604. # Read client ID
  605. client_id_len = (payload[idx] << 8) | payload[idx + 1]
  606. idx += 2
  607. # client_id = payload[idx : idx + client_id_len].decode("utf-8")
  608. idx += client_id_len
  609. # Read username
  610. username_len = (payload[idx] << 8) | payload[idx + 1]
  611. idx += 2
  612. username = payload[idx : idx + username_len].decode("utf-8")
  613. idx += username_len
  614. # Read password
  615. password_len = (payload[idx] << 8) | payload[idx + 1]
  616. idx += 2
  617. password = payload[idx : idx + password_len].decode("utf-8")
  618. # Authenticate. ``hmac.compare_digest`` is constant-time to keep
  619. # the auth check from leaking the access code via response timing
  620. # under network jitter — LAN-only threat is marginal, but it's
  621. # the standard fix and costs nothing.
  622. if username == "bblp" and hmac.compare_digest(password, self.access_code):
  623. # Send CONNACK with success
  624. writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
  625. await writer.drain()
  626. logger.info("%sMQTT client authenticated successfully", self._log_prefix)
  627. # Send immediate status report after auth - slicer expects this
  628. await self._send_status_report(writer)
  629. return True, keep_alive
  630. else:
  631. # Send CONNACK with auth failure
  632. writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized
  633. await writer.drain()
  634. logger.warning("%sMQTT auth failed for user '%s' (access code mismatch)", self._log_prefix, username)
  635. return False, 0
  636. except (IndexError, ValueError) as e:
  637. logger.debug("MQTT CONNECT parse error: %s", e)
  638. # Send CONNACK with error
  639. writer.write(bytes([0x20, 0x02, 0x00, 0x02])) # Protocol error
  640. await writer.drain()
  641. return False, 0
  642. async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
  643. """Handle MQTT SUBSCRIBE packet."""
  644. try:
  645. # Parse packet ID
  646. packet_id = (payload[0] << 8) | payload[1]
  647. # Parse topic filters (just acknowledge them)
  648. idx = 2
  649. granted_qos = []
  650. learned_serial: str | None = None
  651. while idx < len(payload):
  652. topic_len = (payload[idx] << 8) | payload[idx + 1]
  653. idx += 2
  654. topic = payload[idx : idx + topic_len].decode("utf-8")
  655. idx += topic_len
  656. requested_qos = payload[idx]
  657. idx += 1
  658. logger.info("%sMQTT subscribe: %s QoS=%s", self._log_prefix, topic, requested_qos)
  659. granted_qos.append(min(requested_qos, 1)) # Grant up to QoS 1
  660. # Remember the serial the slicer is listening on so status/version
  661. # responses go to a topic it actually subscribed to.
  662. if learned_serial is None:
  663. extracted = self._extract_serial_from_topic(topic)
  664. if extracted:
  665. learned_serial = extracted
  666. if learned_serial and learned_serial != self._client_serials.get(client_id):
  667. if learned_serial != self.serial:
  668. logger.info(
  669. "%sMQTT client subscribed with serial %s (VP serial is %s) — adapting responses",
  670. self._log_prefix,
  671. learned_serial,
  672. self.serial,
  673. )
  674. self._client_serials[client_id] = learned_serial
  675. # Send SUBACK
  676. suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF])
  677. suback += bytes(granted_qos)
  678. writer.write(suback)
  679. await writer.drain()
  680. # Send initial status report after subscribe on the client's subscribed topic
  681. await self._send_status_report(writer, serial=self._client_serials.get(client_id, self.serial))
  682. except (IndexError, ValueError, OSError) as e:
  683. logger.debug("MQTT SUBSCRIBE error: %s", e)
  684. async def _send_status_report(self, writer: asyncio.StreamWriter, serial: str | None = None) -> None:
  685. """Send a status report to the slicer after connection.
  686. When a bridge is active and has cached the real printer's latest
  687. push_status, send a copy of the real push with only the upload-state-
  688. machine fields we own (gcode_state, gcode_file, prepare_percent,
  689. subtask_name) overridden. BambuStudio's Send pre-flight checks the
  690. push_status shape against what it expects from the printer model, and
  691. the synthetic stub introduced fields the real H2D doesn't have (storage,
  692. the wrong chamber_temper shape, etc.) which trip the check.
  693. """
  694. try:
  695. self._sequence_id += 1
  696. cached = self._bridge.get_latest_print_state() if self._bridge is not None else None
  697. if isinstance(cached, dict):
  698. # Real-printer-shaped response. Copy the cache, then replace the
  699. # protocol / upload-state fields with values under our control.
  700. # Deep copy — current mutations are top-level only, but a future
  701. # override that writes into a nested dict (e.g. ``online``,
  702. # ``upgrade_state``, ``ipcam``) would otherwise corrupt the
  703. # bridge cache and be read by every subsequent subscriber until
  704. # the next real-printer push lands. Cost is one allocation per
  705. # status report; the cached dict is already short-lived.
  706. print_block = copy.deepcopy(cached)
  707. print_block["sequence_id"] = str(self._sequence_id)
  708. print_block["command"] = "push_status"
  709. print_block["msg"] = 0
  710. print_block["gcode_state"] = self._gcode_state
  711. print_block["gcode_file"] = self._current_file
  712. print_block["gcode_file_prepare_percent"] = self._prepare_percent
  713. if self._current_file:
  714. print_block["subtask_name"] = self._current_file.replace(".3mf", "")
  715. else:
  716. # Don't override real subtask_name with empty if no upload pending.
  717. print_block.setdefault("subtask_name", "")
  718. # Storage-availability indicators the slicer's "Send" pre-flight reads
  719. # (#1228). P1S/A1-class firmware doesn't always include these in
  720. # push_status (no SD card inserted, older field shapes), and BambuStudio
  721. # rejects the send pre-flight with the generic "storage needs to be
  722. # inserted before send to printer" error before even attempting FTP.
  723. # For VP usage the slicer uploads via FTPS to Bambuddy's filesystem —
  724. # the printer's actual SD/storage state is irrelevant on that path.
  725. # Force "available" indicators so the pre-flight passes regardless of
  726. # what the real printer reports. Restores the 0.2.3.2 synthetic-stub
  727. # behaviour for these fields without losing the live AMS / k-profile /
  728. # camera mirror cached-as-base provides.
  729. print_block["home_flag"] = print_block.get("home_flag", 0) | 0x100 # bit 8 = HAS_SDCARD_NORMAL
  730. print_block["sdcard"] = True
  731. print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000})
  732. # Live-progress fields the slicer's Send pre-flight reads
  733. # (#1558). When the real target printer is mid-print, the
  734. # cached push_status carries the real values for these
  735. # fields and the slicer reads the VP as "busy" — refusing
  736. # Send — even though gcode_state above is forced to IDLE.
  737. # For VP usage the VP isn't actually running the print
  738. # the printer is, so these need to mirror the synthetic
  739. # stub's idle values. Same shape as #1228 (storage) — the
  740. # cached-branch override set just needed extending.
  741. print_block["mc_print_stage"] = ""
  742. print_block["mc_percent"] = 0
  743. print_block["mc_remaining_time"] = 0
  744. print_block["stg"] = []
  745. print_block["stg_cur"] = 0
  746. print_block["layer_num"] = 0
  747. print_block["total_layer_num"] = 0
  748. print_block["print_error"] = 0
  749. status = {"print": print_block}
  750. await self._publish_to_report(writer, status, serial or self.serial)
  751. return
  752. # No bridge / no cache yet — fall back to the synthetic stub.
  753. status = {
  754. "print": {
  755. "sequence_id": str(self._sequence_id),
  756. "command": "push_status",
  757. "msg": 0,
  758. "gcode_state": self._gcode_state,
  759. "gcode_file": self._current_file,
  760. "gcode_file_prepare_percent": self._prepare_percent,
  761. "subtask_name": self._current_file.replace(".3mf", "") if self._current_file else "",
  762. "mc_print_stage": "",
  763. "mc_percent": 0,
  764. "mc_remaining_time": 0,
  765. "wifi_signal": "-44dBm",
  766. "print_error": 0,
  767. "print_type": "",
  768. "bed_temper": 25.0,
  769. "bed_target_temper": 0.0,
  770. "nozzle_temper": 25.0,
  771. "nozzle_target_temper": 0.0,
  772. "chamber_temper": 25.0,
  773. "cooling_fan_speed": "0",
  774. "big_fan1_speed": "0",
  775. "big_fan2_speed": "0",
  776. "heatbreak_fan_speed": "0",
  777. "spd_lvl": 1,
  778. "spd_mag": 100,
  779. "stg": [],
  780. "stg_cur": 0,
  781. "layer_num": 0,
  782. "total_layer_num": 0,
  783. "home_flag": 256, # Bit 8 = SD card present (HAS_SDCARD_NORMAL)
  784. "hw_switch_state": 0,
  785. "online": {"ahb": False, "rfid": False, "version": 7},
  786. "ams_status": 0,
  787. "sdcard": True,
  788. "storage": {"free": 1000000000, "total": 32000000000},
  789. "upgrade_state": {
  790. "sequence_id": 0,
  791. "progress": "",
  792. "status": "",
  793. "consistency_request": False,
  794. "dis_state": 0,
  795. "err_code": 0,
  796. "force_upgrade": False,
  797. "message": "",
  798. "module": "",
  799. "new_version_state": 2,
  800. "new_ver_list": [],
  801. "ota_new_version_number": "",
  802. "ahb_new_version_number": "",
  803. },
  804. "ipcam": {
  805. "ipcam_dev": "1",
  806. "ipcam_record": "enable",
  807. "timelapse": "disable",
  808. "resolution": "1080p",
  809. "mode_bits": 0,
  810. },
  811. "xcam": {
  812. "allow_skip_parts": False,
  813. "buildplate_marker_detector": True,
  814. "first_layer_inspector": True,
  815. "halt_print_sensitivity": "medium",
  816. "print_halt": True,
  817. "printing_monitor": True,
  818. "spaghetti_detector": True,
  819. },
  820. "lights_report": [{"node": "chamber_light", "mode": "on"}],
  821. "nozzle_diameter": "0.4",
  822. "nozzle_type": "hardened_steel",
  823. }
  824. }
  825. await self._publish_to_report(writer, status, serial or self.serial)
  826. except OSError as e:
  827. logger.error("Failed to send status report: %s", e)
  828. async def _send_version_response(
  829. self, writer: asyncio.StreamWriter, sequence_id: str, serial: str | None = None
  830. ) -> None:
  831. """Send version info response to the slicer."""
  832. try:
  833. product_name = MODEL_PRODUCT_NAMES.get(self.model, self.model or "X1 Carbon")
  834. # The serial is embedded inside the module[].sn fields *and* used as
  835. # the report topic. Use the client's effective serial so the slicer
  836. # sees internal/topic consistency even when it differs from self.serial.
  837. serial = serial or self.serial
  838. # Build version response matching OrcaSlicer expectations
  839. # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
  840. version_info = {
  841. "info": {
  842. "command": "get_version",
  843. "sequence_id": sequence_id,
  844. "module": [
  845. {
  846. "name": "ota",
  847. "product_name": product_name,
  848. "sw_ver": "01.07.00.00",
  849. "sw_new_ver": "",
  850. "hw_ver": "OTA",
  851. "sn": serial,
  852. "flag": 0,
  853. },
  854. {
  855. "name": "esp32",
  856. "product_name": product_name,
  857. "sw_ver": "01.07.22.25",
  858. "sw_new_ver": "",
  859. "hw_ver": "AP05",
  860. "sn": serial,
  861. "flag": 0,
  862. },
  863. {
  864. "name": "rv1126",
  865. "product_name": product_name,
  866. "sw_ver": "00.00.27.38",
  867. "sw_new_ver": "",
  868. "hw_ver": "AP05",
  869. "sn": serial,
  870. "flag": 0,
  871. },
  872. {
  873. "name": "th",
  874. "product_name": product_name,
  875. "sw_ver": "00.00.04.00",
  876. "sw_new_ver": "",
  877. "hw_ver": "TH07",
  878. "sn": serial,
  879. "flag": 0,
  880. },
  881. {
  882. "name": "mc",
  883. "product_name": product_name,
  884. "sw_ver": "00.00.10.00",
  885. "sw_new_ver": "",
  886. "hw_ver": "MC07",
  887. "sn": serial,
  888. "flag": 0,
  889. },
  890. ],
  891. }
  892. }
  893. # Overlay real version modules from the bridge cache when available
  894. # (specifically the AMS modules ams/0, n3f/0, n3s/128 etc. that
  895. # BambuStudio's Prepare tab uses to identify AMS hardware — without
  896. # them every AMS unit shows as "unknown" in the Prepare panel).
  897. if self._bridge is not None:
  898. cached_modules = self._bridge.get_latest_version_modules()
  899. if isinstance(cached_modules, list) and cached_modules:
  900. version_info["info"]["module"] = cached_modules
  901. await self._publish_to_report(writer, version_info, serial)
  902. logger.info("Sent version response (product_name=%s)", product_name)
  903. except OSError as e:
  904. logger.error("Failed to send version response: %s", e)
  905. def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None:
  906. """Update the gcode state reported to connected slicers.
  907. Called by the manager to reflect FTP upload progress/completion.
  908. """
  909. self._gcode_state = state
  910. self._current_file = filename
  911. self._prepare_percent = prepare_percent
  912. async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = "") -> None:
  913. """Publish a message on the device report topic.
  914. Real Bambu printers wire-format push_status JSON with 4-space indentation
  915. (32254 bytes for an idle H2D push vs 14268 bytes compact). BambuStudio's
  916. Send pre-flight rejects compact JSON — without matching the on-wire
  917. format the slicer never proceeds to FTP upload.
  918. """
  919. topic = f"device/{serial or self.serial}/report"
  920. message = json.dumps(payload, indent=4)
  921. topic_bytes = topic.encode("utf-8")
  922. message_bytes = message.encode("utf-8")
  923. remaining = 2 + len(topic_bytes) + len(message_bytes)
  924. packet = bytes([0x30]) # PUBLISH, QoS 0
  925. while remaining > 0:
  926. byte = remaining % 128
  927. remaining //= 128
  928. if remaining > 0:
  929. byte |= 0x80
  930. packet += bytes([byte])
  931. packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
  932. packet += topic_bytes
  933. packet += message_bytes
  934. writer.write(packet)
  935. # Timeout the drain to prevent blocking the event loop if the
  936. # MQTT client stops reading (e.g. slicer busy with FTP upload).
  937. try:
  938. await asyncio.wait_for(writer.drain(), timeout=5)
  939. except TimeoutError:
  940. logger.debug("MQTT drain timeout for %s — client may be busy", topic)
  941. async def _send_print_response(
  942. self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None
  943. ) -> None:
  944. """Send project_file acknowledgment matching real Bambu printer behavior."""
  945. # Update state so periodic status pushes reflect preparation
  946. self._gcode_state = "PREPARE"
  947. self._current_file = filename
  948. self._prepare_percent = "0"
  949. try:
  950. # Send command acknowledgment — slicer expects to see
  951. # command: "project_file" echoed back before starting FTP upload
  952. subtask_name = filename.replace(".3mf", "") if filename else ""
  953. response = {
  954. "print": {
  955. "command": "project_file",
  956. "sequence_id": sequence_id,
  957. "param": "Metadata/plate_1.gcode",
  958. "subtask_name": subtask_name,
  959. "gcode_state": "PREPARE",
  960. "gcode_file": filename,
  961. "gcode_file_prepare_percent": "0",
  962. "result": "SUCCESS",
  963. "msg": 0,
  964. }
  965. }
  966. await self._publish_to_report(writer, response, serial or self.serial)
  967. logger.info("Sent project_file acknowledgment for %s", filename)
  968. except OSError as e:
  969. logger.error("Failed to send print response: %s", e)
  970. async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
  971. """Handle MQTT PUBLISH packet."""
  972. try:
  973. # Parse topic
  974. idx = 0
  975. topic_len = (payload[idx] << 8) | payload[idx + 1]
  976. idx += 2
  977. topic = payload[idx : idx + topic_len].decode("utf-8")
  978. idx += topic_len
  979. # Check for packet ID (QoS > 0)
  980. qos = (header & 0x06) >> 1
  981. if qos > 0:
  982. # packet_id = (payload[idx] << 8) | payload[idx + 1]
  983. idx += 2
  984. # Parse message
  985. message = payload[idx:].decode("utf-8")
  986. logger.info("MQTT publish to %s: %s...", topic, message[:100])
  987. # Only handle publishes on *some* device/.../request topic. The
  988. # serial is taken from the topic rather than compared against
  989. # self.serial: the client is already authenticated via the access
  990. # code, and Orca/BambuStudio may have a cached serial that differs
  991. # from the VP's computed self.serial (#927). Use the topic's serial
  992. # for all responses so they land on the topic the slicer subscribed
  993. # to.
  994. if not topic.startswith("device/") or "/request" not in topic:
  995. return
  996. client_serial = self._extract_serial_from_topic(topic) or self.serial
  997. if client_serial and client_serial != self._client_serials.get(client_id):
  998. if client_serial != self.serial:
  999. logger.info(
  1000. "%sMQTT client publishing with serial %s (VP serial is %s) — adapting responses",
  1001. self._log_prefix,
  1002. client_serial,
  1003. self.serial,
  1004. )
  1005. self._client_serials[client_id] = client_serial
  1006. try:
  1007. # Some slicer builds (observed with OrcaSlicer on Linux, #927)
  1008. # include the C-string null terminator in the MQTT payload
  1009. # length, so the decoded message ends with \x00. Real brokers
  1010. # pass the bytes through; strict json.loads raises "Extra data"
  1011. # and every pushall/get_version/project_file silently dropped.
  1012. data = json.loads(message.rstrip("\x00 \r\n\t"))
  1013. except json.JSONDecodeError as e:
  1014. logger.debug(
  1015. "MQTT publish JSON decode failed: %s (payload=%r)",
  1016. e,
  1017. message[:200],
  1018. )
  1019. return
  1020. # The synthetic flow below is the original (pre-bridge) behaviour and is
  1021. # what the proven-working FTP "Send" depends on. Do NOT replace any
  1022. # synthetic response with a forward — only ADD forwarding alongside,
  1023. # at the bottom, for commands the synthetic flow doesn't handle
  1024. # (AMS write / xcam / system / etc., which need to actually reach
  1025. # the real printer).
  1026. handled_locally = False
  1027. # Handle pushing command (status request)
  1028. if "pushing" in data:
  1029. pushing_data = data["pushing"]
  1030. command = pushing_data.get("command", "")
  1031. logger.info("MQTT pushing command: %s", command)
  1032. if command == "pushall":
  1033. logger.info("Sending status report in response to pushall")
  1034. await self._send_status_report(writer, serial=client_serial)
  1035. handled_locally = True
  1036. elif command == "start":
  1037. logger.info("Starting status push stream")
  1038. await self._send_status_report(writer, serial=client_serial)
  1039. handled_locally = True
  1040. # Handle info commands (get_version, etc.)
  1041. if "info" in data:
  1042. info_data = data["info"]
  1043. command = info_data.get("command", "")
  1044. sequence_id = info_data.get("sequence_id", "0")
  1045. logger.info("MQTT info command: %s", command)
  1046. if command == "get_version":
  1047. await self._send_version_response(writer, sequence_id, serial=client_serial)
  1048. handled_locally = True
  1049. # Handle print commands
  1050. if "print" in data:
  1051. print_data = data["print"]
  1052. command = print_data.get("command", "")
  1053. filename = print_data.get("subtask_name", "")
  1054. sequence_id = print_data.get("sequence_id", "0")
  1055. logger.info("MQTT print command: %s for %s", command, filename)
  1056. if command in ("project_file", "gcode_file"):
  1057. # File lives on Bambuddy, not the printer — synthetic only.
  1058. file_3mf = print_data.get("file", filename)
  1059. await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
  1060. if self.on_print_command:
  1061. await self._notify_print_command(filename, print_data)
  1062. handled_locally = True
  1063. # Forward anything the synthetic flow didn't handle to the real
  1064. # printer. AMS load / dry / xcam / system / extrusion_cali_get etc.
  1065. if not handled_locally and self._bridge is not None and self._bridge.is_active:
  1066. # Remember which client originated this command so the
  1067. # printer's response goes back only to them (not fanned
  1068. # out to every connected slicer).
  1069. self._record_pending_request(data, client_id)
  1070. self._bridge.forward_to_printer(data)
  1071. except (IndexError, ValueError, OSError) as e:
  1072. logger.debug("MQTT PUBLISH error: %s", e)
  1073. async def _notify_print_command(self, filename: str, data: dict) -> None:
  1074. """Notify callback of print command."""
  1075. if self.on_print_command:
  1076. try:
  1077. result = self.on_print_command(filename, data)
  1078. if asyncio.iscoroutine(result):
  1079. await result
  1080. except Exception as e:
  1081. logger.error("Print command callback error: %s", e)