mqtt_server.py 64 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376
  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 socket
  11. import ssl
  12. import time
  13. from collections.abc import Awaitable, Callable
  14. from pathlib import Path
  15. from typing import TYPE_CHECKING
  16. from backend.app.services.virtual_printer._debug import append_event, dump_wire
  17. if TYPE_CHECKING:
  18. from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
  19. logger = logging.getLogger(__name__)
  20. # Slicer commands logged in full, not cut at 100 characters: they say how the
  21. # slicer addresses an AMS tray (see _handle_publish).
  22. _COMMANDS_LOGGED_WHOLE = frozenset(
  23. {"extrusion_cali_sel", "extrusion_cali_set", "ams_filament_setting", "ams_change_filament"}
  24. )
  25. # Default MQTT port for Bambu printers (MQTT over TLS)
  26. MQTT_PORT = 8883
  27. # Per-IP MQTT auth rate-limit. 5 failures within 60 s blocks further attempts
  28. # for the remainder of the window. Bambu printers themselves don't rate-limit,
  29. # but they're not exposed past the LAN edge; Bambuddy's VPs sometimes are
  30. # (Tailscale, port-forwarded), so an 8-char access code without any
  31. # brute-force friction is too weak. The window auto-recovers — no manual
  32. # unblock — so a legitimate user who fat-fingered their access code 5 times
  33. # only waits up to 60 s.
  34. _AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
  35. _AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
  36. # Pending-request map bound. Each entry maps a slicer command's
  37. # sequence_id to its originating client_id so the bridge response can be
  38. # routed back to just that client. Bounded so a slicer that issues
  39. # commands without ever consuming responses can't leak memory.
  40. _PENDING_REQUEST_MAX_ENTRIES = 256
  41. # Target-printer gcode_states for which the VP mirrors live print progress to
  42. # the slicer (#1887). BambuStudio and OrcaSlicer gate BOTH the Device-tab
  43. # progress panel and the Send button on the same predicate —
  44. # `MachineObject::is_in_printing()`, i.e. gcode_state in
  45. # {RUNNING, PAUSE, SLICING, PREPARE} — so reporting the real state verbatim
  46. # would show progress at the cost of blocking Send for as long as the printer
  47. # prints. That is exactly the #1558 regression. `FINISH` is the one state that
  48. # renders the progress panel (`is_in_printing() || print_status == "FINISH"` in
  49. # StatusPanel::update_subtask) while leaving Send enabled, so the mirror keeps
  50. # reporting FINISH and only fills in the numbers underneath it.
  51. _MIRRORED_PRINT_STATES = frozenset({"RUNNING", "PAUSE"})
  52. # How long after the last upload-state transition the VP keeps echoing the
  53. # slicer's own filename back at it before switching the report over to whatever
  54. # the target printer is really printing. The slicer releases its in-flight-job
  55. # lock when it sees gcode_state=FINISH carrying the subtask_name it uploaded
  56. # (#1280 / #1658); swapping in the printer's filename while that handshake is
  57. # still in flight wedges the send modal at "Downloading". 5 s covers the 1.5 s
  58. # `_schedule_finish_release` timer plus several 1 Hz pushes.
  59. _UPLOAD_SETTLE_SECONDS = 5.0
  60. # Model code → product_name for version response (must match what slicer expects)
  61. MODEL_PRODUCT_NAMES = {
  62. "BL-P001": "X1 Carbon",
  63. "BL-P002": "X1",
  64. "C13": "X1E",
  65. "N6": "X2D",
  66. "N9": "A2L",
  67. "C11": "P1P",
  68. "C12": "P1S",
  69. "N7": "P2S",
  70. "N2S": "A1",
  71. "N1": "A1 mini",
  72. "O1D": "H2D",
  73. "O1C": "H2C",
  74. "O1C2": "H2C",
  75. "O1S": "H2S",
  76. }
  77. class SimpleMQTTServer:
  78. """The virtual printer's MQTT server, on raw sockets.
  79. Handles the part of the MQTT protocol slicers use with a printer; not a
  80. general-purpose broker.
  81. """
  82. def __init__(
  83. self,
  84. serial: str,
  85. access_code: str,
  86. cert_path: Path,
  87. key_path: Path,
  88. port: int = MQTT_PORT,
  89. on_print_command: Callable[[str, dict], None] | None = None,
  90. model: str = "",
  91. bind_address: str = "0.0.0.0", # nosec B104
  92. vp_name: str = "",
  93. authenticate_user: Callable[[str], Awaitable[int | None]] | None = None,
  94. authorize_control: Callable[[int], Awaitable[bool]] | None = None,
  95. ):
  96. self.serial = serial
  97. self.access_code = access_code
  98. # A personal access code (#1430): returns the user's id, or None.
  99. self.authenticate_user = authenticate_user
  100. # Whether that user may send commands to the target printer now. The
  101. # bridge forwards a slicer's commands (stop, pause, G-code, AMS) to the
  102. # real printer; a personal code alone only lets someone send files.
  103. self.authorize_control = authorize_control
  104. # The user each connection signed in as, for those that used a
  105. # personal code (keyed by the connection's writer).
  106. self._connection_users: dict[asyncio.StreamWriter, int] = {}
  107. self.model = model
  108. self.cert_path = cert_path
  109. self.key_path = key_path
  110. self.port = port
  111. self.on_print_command = on_print_command
  112. self.bind_address = bind_address
  113. self.vp_name = vp_name
  114. self._log_prefix = f"[{vp_name}] " if vp_name else ""
  115. self._running = False
  116. # Set after the socket is bound — see ftp_server.py for rationale.
  117. self.ready = asyncio.Event()
  118. self._server = None
  119. self._clients: dict[str, asyncio.StreamWriter] = {}
  120. # Per-client "effective serial" — the serial the slicer actually uses in
  121. # device/{serial}/report|request topics. Populated from the first
  122. # SUBSCRIBE/PUBLISH we see on a connection. This lets the VP respond on
  123. # the topic the slicer is listening on even when it disagrees with
  124. # self.serial (e.g. a stale Orca config that was bound to an older VP
  125. # serial, or a printer entry that was re-pointed at the VP IP without
  126. # updating the serial).
  127. self._client_serials: dict[str, str] = {}
  128. self._status_push_task: asyncio.Task | None = None
  129. self._sequence_id = 0
  130. # Dynamic state for status reports
  131. self._gcode_state = "IDLE"
  132. self._current_file = ""
  133. self._prepare_percent = "0"
  134. # Monotonic timestamp of the last upload-state transition, so the live-
  135. # progress mirror can tell whether the slicer is still waiting on its
  136. # own upload handshake. Starts at -inf: a VP that has never seen an
  137. # upload has no handshake to protect and can mirror immediately.
  138. self._state_changed_at = float("-inf")
  139. # MQTT bridge for non-proxy modes — set by VirtualPrinterInstance after start().
  140. # When the bridge is_active, real printer pushes are fanned out to slicers and
  141. # the synthetic 1s push is suspended. When the target printer goes offline the
  142. # synthetic fallback resumes automatically.
  143. self._bridge: MQTTBridge | None = None
  144. # Per-source-IP failed-auth tracker for rate-limiting / lockout.
  145. # Maps IP → list[monotonic timestamp] of recent failures within the
  146. # window. Pruned on every check so it doesn't grow unbounded.
  147. self._auth_failures: dict[str, list[float]] = {}
  148. # Maps sequence_id → originating client_id for slicer-initiated
  149. # commands forwarded to the real printer. Used in
  150. # ``push_raw_to_clients`` to route the printer's response only
  151. # back to the requesting slicer instead of fanning out to all
  152. # connected clients (which leaks slicer A's responses to slicer
  153. # B in multi-slicer setups). FIFO-bounded; if a response never
  154. # arrives the entry ages out instead of leaking.
  155. self._pending_requests: dict[str, str] = {}
  156. async def start(self) -> None:
  157. """Start the MQTT server."""
  158. if self._running:
  159. return
  160. logger.info("Starting simple MQTT server on port %s", self.port)
  161. # Create SSL context with Bambu-compatible settings
  162. ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  163. ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
  164. # Match Bambu printer behavior - accept any client
  165. ssl_context.verify_mode = ssl.CERT_NONE
  166. # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
  167. ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
  168. # Match real Bambu printer cipher behaviour: include the plain-RSA
  169. # AES-GCM suites the slicer expects. On hardened distros
  170. # (Fedora / RHEL with `update-crypto-policies`, hardened Alpine builds)
  171. # OpenSSL's `DEFAULT` list strips these suites, leaving no overlap
  172. # with the slicer's MQTT-over-TLS ClientHello — handshake fails
  173. # immediately and the slicer reports a connect error before any MQTT
  174. # CONNECT can be sent (#1610 audit). Same shape as the #620 fix.
  175. ssl_context.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  176. # Disable hostname checking
  177. ssl_context.check_hostname = False
  178. # Log certificate info
  179. import subprocess
  180. try:
  181. result = subprocess.run(
  182. ["openssl", "x509", "-in", str(self.cert_path), "-noout", "-subject", "-issuer"],
  183. capture_output=True,
  184. text=True,
  185. timeout=5,
  186. )
  187. logger.info("MQTT SSL cert info: %s", result.stdout.strip())
  188. except (OSError, subprocess.SubprocessError):
  189. pass # Certificate info is for debug logging only; not critical
  190. logger.info("MQTT SSL context: TLS 1.2+, cert=%s", self.cert_path)
  191. try:
  192. self._running = True
  193. # Wrapper to log ALL connection attempts including SSL errors
  194. async def connection_handler(reader, writer):
  195. try:
  196. addr = writer.get_extra_info("peername")
  197. ssl_obj = writer.get_extra_info("ssl_object")
  198. if ssl_obj:
  199. logger.info(
  200. f"{self._log_prefix}MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
  201. )
  202. else:
  203. logger.info("%sMQTT connection from %s (no TLS?)", self._log_prefix, addr)
  204. await self._handle_client(reader, writer)
  205. except ssl.SSLError as e:
  206. logger.error("MQTT SSL error: %s", e)
  207. except Exception as e:
  208. logger.error("MQTT connection handler error: %s", e)
  209. self._server = await asyncio.start_server(
  210. connection_handler,
  211. self.bind_address,
  212. self.port,
  213. ssl=ssl_context,
  214. )
  215. self.ready.set()
  216. logger.info("Simple MQTT server listening on port %s", self.port)
  217. # Start periodic status push task
  218. self._status_push_task = asyncio.create_task(self._periodic_status_push())
  219. async with self._server:
  220. await self._server.serve_forever()
  221. except OSError as e:
  222. if e.errno == 98: # Address already in use
  223. logger.error("MQTT port %s is already in use", self.port)
  224. else:
  225. logger.error("MQTT server error: %s", e)
  226. except asyncio.CancelledError:
  227. logger.debug("MQTT server task cancelled")
  228. except Exception as e:
  229. logger.error("MQTT server error: %s", e)
  230. finally:
  231. await self.stop()
  232. async def stop(self) -> None:
  233. """Stop the MQTT server."""
  234. logger.info("Stopping simple MQTT server")
  235. self._running = False
  236. self.ready.clear()
  237. # Stop periodic status push
  238. if self._status_push_task:
  239. self._status_push_task.cancel()
  240. try:
  241. await self._status_push_task
  242. except asyncio.CancelledError:
  243. pass # Expected when stopping the periodic status push task
  244. self._status_push_task = None
  245. # Close all client connections (iterate over copy to avoid modification during iteration)
  246. for _client_id, writer in list(self._clients.items()):
  247. try:
  248. writer.close()
  249. await writer.wait_closed()
  250. except OSError:
  251. pass # Best-effort client connection cleanup; client may have disconnected
  252. self._clients.clear()
  253. self._client_serials.clear()
  254. if self._server:
  255. try:
  256. self._server.close()
  257. await self._server.wait_closed()
  258. except OSError:
  259. pass # Best-effort server shutdown; port may already be released
  260. self._server = None
  261. @staticmethod
  262. def _extract_serial_from_topic(topic: str) -> str | None:
  263. """Pull the serial out of a `device/{serial}/report|request` topic.
  264. Returns None if the topic doesn't match that shape — callers fall back
  265. to self.serial in that case.
  266. """
  267. if not topic.startswith("device/"):
  268. return None
  269. rest = topic[len("device/") :]
  270. # Expect "{serial}/report" or "{serial}/request" (possibly with suffixes).
  271. slash = rest.find("/")
  272. if slash <= 0:
  273. return None
  274. return rest[:slash]
  275. def set_bridge(self, bridge: "MQTTBridge | None") -> None:
  276. """Attach (or detach) the MQTT bridge that mirrors the target printer."""
  277. self._bridge = bridge
  278. async def _periodic_status_push(self) -> None:
  279. """Send periodic status updates to all connected clients (1 Hz, exact pre-bridge behaviour)."""
  280. logger.info("Starting periodic status push task")
  281. # Per-client push counters reset every 60 ticks. Lets us confirm from
  282. # logs whether the 1Hz push is actually reaching a specific slicer
  283. # connection (#1548 keepalive follow-up: keepalive parser shipped but
  284. # OrcaSlicer still disconnects on idle, and the periodic push is
  285. # otherwise silent at INFO level so it can't be observed in the
  286. # support bundle). One log line per minute per active connection —
  287. # nothing when no slicer is attached.
  288. push_counts: dict[str, int] = {}
  289. ticks_since_summary = 0
  290. while self._running:
  291. try:
  292. await asyncio.sleep(1) # Push every 1 second like real printers
  293. ticks_since_summary += 1
  294. disconnected = []
  295. for client_id, writer in list(self._clients.items()):
  296. try:
  297. if writer.is_closing():
  298. disconnected.append(client_id)
  299. continue
  300. serial = self._client_serials.get(client_id, self.serial)
  301. # log_event=False: the 1Hz cached push is already
  302. # captured by ``dump_wire`` snapshot mode (see
  303. # _debug.py); appending it to the cmd.jsonl would
  304. # flood the file ~60 lines/min per VP.
  305. await self._send_status_report(writer, serial=serial, log_event=False)
  306. push_counts[client_id] = push_counts.get(client_id, 0) + 1
  307. except OSError as e:
  308. logger.debug("Failed to push status to %s: %s", client_id, e)
  309. disconnected.append(client_id)
  310. # Remove disconnected clients
  311. for client_id in disconnected:
  312. self._clients.pop(client_id, None)
  313. self._client_serials.pop(client_id, None)
  314. push_counts.pop(client_id, None)
  315. if ticks_since_summary >= 60:
  316. for cid, count in push_counts.items():
  317. logger.info(
  318. "%s1Hz status push: %d pushes/min to %s",
  319. self._log_prefix,
  320. count,
  321. cid,
  322. )
  323. push_counts.clear()
  324. ticks_since_summary = 0
  325. except asyncio.CancelledError:
  326. break
  327. except Exception as e:
  328. logger.error("Periodic status push error: %s", e)
  329. logger.info("Periodic status push task stopped")
  330. async def push_raw_to_clients(self, topic: str, payload: bytes) -> None:
  331. """Publish a pre-serialized MQTT payload on `topic` to connected slicers.
  332. Called by MQTTBridge from the asyncio loop (scheduled via
  333. run_coroutine_threadsafe from paho's network thread).
  334. Routes the response only back to the originating slicer if the
  335. payload's sequence_id was previously recorded via
  336. ``_record_pending_request``. Falls back to fan-out for
  337. printer-initiated pushes (push_status etc.) and for sequence_ids
  338. we never saw (covers a slicer that subscribes mid-flight to a
  339. topic for which an earlier request is still in flight).
  340. """
  341. topic_bytes = topic.encode("utf-8")
  342. # MQTT remaining-length: 2-byte topic length prefix + topic + message body.
  343. remaining = 2 + len(topic_bytes) + len(payload)
  344. packet = bytearray([0x30]) # PUBLISH, QoS 0
  345. while True:
  346. byte = remaining % 128
  347. remaining //= 128
  348. if remaining > 0:
  349. byte |= 0x80
  350. packet.append(byte)
  351. if remaining == 0:
  352. break
  353. packet.extend([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
  354. packet.extend(topic_bytes)
  355. packet.extend(payload)
  356. frame = bytes(packet)
  357. target_client_id = self._lookup_pending_request_client(payload)
  358. disconnected = []
  359. for client_id, writer in list(self._clients.items()):
  360. if target_client_id is not None and client_id != target_client_id:
  361. continue
  362. try:
  363. if writer.is_closing():
  364. disconnected.append(client_id)
  365. continue
  366. writer.write(frame)
  367. try:
  368. await asyncio.wait_for(writer.drain(), timeout=5)
  369. except TimeoutError:
  370. logger.debug("MQTT drain timeout pushing bridge frame to %s", client_id)
  371. except OSError as e:
  372. logger.debug("Failed to push bridge frame to %s: %s", client_id, e)
  373. disconnected.append(client_id)
  374. for client_id in disconnected:
  375. self._clients.pop(client_id, None)
  376. self._client_serials.pop(client_id, None)
  377. async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  378. """Handle an MQTT client connection."""
  379. addr = writer.get_extra_info("peername")
  380. client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
  381. logger.info("%sMQTT client connected: %s", self._log_prefix, client_id)
  382. authenticated = False
  383. # Per-packet read timeout. Before CONNECT we default to 60 s so a
  384. # client that opens TCP but never sends anything still gets reaped.
  385. # After CONNECT we drop the application-level read timeout entirely
  386. # and rely on TCP keepalive (SO_KEEPALIVE) to detect dead connections
  387. # — this matches real Bambu firmware, which does not enforce MQTT
  388. # spec §4.4's 1.5× idle disconnect (#1548 round 2). OrcaSlicer's
  389. # MQTT client on some platforms does not emit PINGREQ at all on idle
  390. # connections; the same install that stays connected to a real P1S
  391. # indefinitely was disconnecting from us at keepalive×1.5.
  392. read_timeout: float | None = 60.0
  393. try:
  394. while self._running:
  395. # Read MQTT fixed header
  396. try:
  397. header = await asyncio.wait_for(reader.read(1), timeout=read_timeout)
  398. except TimeoutError:
  399. break
  400. if not header:
  401. break
  402. packet_type = (header[0] & 0xF0) >> 4
  403. # Read remaining length
  404. remaining_length = await self._read_remaining_length(reader)
  405. if remaining_length is None:
  406. break
  407. # Read payload
  408. payload = await reader.read(remaining_length) if remaining_length > 0 else b""
  409. # Handle packet types
  410. if packet_type == 1: # CONNECT
  411. source_ip = addr[0] if addr else "unknown"
  412. if self._is_auth_rate_limited(source_ip):
  413. logger.warning(
  414. "%sMQTT auth rate-limited from %s (>=%d failures in %ds)",
  415. self._log_prefix,
  416. source_ip,
  417. _AUTH_RATE_LIMIT_MAX_ATTEMPTS,
  418. int(_AUTH_RATE_LIMIT_WINDOW_SECONDS),
  419. )
  420. writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized
  421. await writer.drain()
  422. break
  423. # Counted before the lookup, so connections in parallel
  424. # can't all pass the check above first.
  425. self._record_auth_failure(source_ip)
  426. authenticated, keep_alive = await self._handle_connect(payload, writer)
  427. if not authenticated:
  428. break
  429. self._release_auth_attempt(source_ip)
  430. # Drop the application-level read timeout; rely on
  431. # SO_KEEPALIVE below for dead-connection detection.
  432. # Real Bambu firmware does the same — accept any
  433. # negotiated keepalive but never enforce §4.4's 1.5×
  434. # disconnect on the otherwise-idle MQTT session
  435. # (#1548 round 2). keep_alive is logged for support
  436. # bundles but no longer drives a disconnect.
  437. read_timeout = None
  438. logger.info(
  439. "%sMQTT client %s authenticated (negotiated keepalive=%ds, idle disconnect disabled)",
  440. self._log_prefix,
  441. client_id,
  442. keep_alive,
  443. )
  444. # Enable TCP keepalive so a hard network drop is detected
  445. # by the OS within a few minutes rather than waiting for
  446. # the next outbound write to ECONNRESET.
  447. #
  448. # Also tighten the Linux keepalive schedule. Defaults are
  449. # tcp_keepalive_time=7200 s (2 h before first probe),
  450. # tcp_keepalive_intvl=75, tcp_keepalive_probes=9 — so a
  451. # macOS client that goes to sleep silently is only
  452. # detected as dead ~2 h 11 min later, and until then the
  453. # push loop keeps stalling on drain-timeouts to the
  454. # zombie socket. #1872: a P1S sleep/wake left the pre-
  455. # sleep session in _clients for 5+ min with no eviction
  456. # signal. New settings (idle=60 s, interval=15 s,
  457. # count=4) detect a dead peer in ~2 min. `getattr` guards
  458. # keep this cross-platform — macOS has TCP_KEEPINTVL but
  459. # not TCP_KEEPIDLE (uses TCP_KEEPALIVE); other platforms
  460. # silently skip.
  461. sock = writer.get_extra_info("socket")
  462. if sock is not None:
  463. try:
  464. sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
  465. except OSError as e:
  466. logger.debug("%sFailed to set SO_KEEPALIVE on %s: %s", self._log_prefix, client_id, e)
  467. for opt_name, opt_value in (
  468. ("TCP_KEEPIDLE", 60),
  469. ("TCP_KEEPINTVL", 15),
  470. ("TCP_KEEPCNT", 4),
  471. ):
  472. opt = getattr(socket, opt_name, None)
  473. if opt is None:
  474. continue
  475. try:
  476. sock.setsockopt(socket.IPPROTO_TCP, opt, opt_value)
  477. except OSError as e:
  478. logger.debug(
  479. "%sFailed to set %s=%s on %s: %s",
  480. self._log_prefix,
  481. opt_name,
  482. opt_value,
  483. client_id,
  484. e,
  485. )
  486. # Register client for periodic status pushes; start with
  487. # self.serial as the fallback until we learn the slicer's
  488. # preferred serial from the first SUBSCRIBE/PUBLISH.
  489. self._clients[client_id] = writer
  490. self._client_serials[client_id] = self.serial
  491. elif packet_type == 3: # PUBLISH
  492. if authenticated:
  493. await self._handle_publish(header[0], payload, writer, client_id)
  494. elif packet_type == 8: # SUBSCRIBE
  495. if authenticated:
  496. await self._handle_subscribe(payload, writer, client_id)
  497. elif packet_type == 12: # PINGREQ
  498. # Send PINGRESP
  499. writer.write(bytes([0xD0, 0x00]))
  500. await writer.drain()
  501. elif packet_type == 14: # DISCONNECT
  502. break
  503. except asyncio.CancelledError:
  504. pass # Expected when server is shutting down and cancels client tasks
  505. except Exception as e:
  506. # Outer handler — inner handlers already absorb expected parser
  507. # / IO failures at debug. Anything reaching here is unexpected
  508. # and would otherwise silently drop the slicer connection with
  509. # no actionable signal in production logs (defaults are INFO+).
  510. logger.warning("%sMQTT client session error from %s: %s", self._log_prefix, client_id, e)
  511. finally:
  512. logger.debug("MQTT client disconnected: %s", client_id)
  513. self._clients.pop(client_id, None)
  514. self._client_serials.pop(client_id, None)
  515. self._connection_users.pop(writer, None)
  516. try:
  517. writer.close()
  518. await writer.wait_closed()
  519. except OSError:
  520. pass # Best-effort socket cleanup on client disconnect
  521. async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:
  522. """Read MQTT remaining length (variable byte integer)."""
  523. multiplier = 1
  524. value = 0
  525. for _ in range(4):
  526. try:
  527. byte = await reader.read(1)
  528. if not byte:
  529. return None
  530. encoded = byte[0]
  531. value += (encoded & 127) * multiplier
  532. if (encoded & 128) == 0:
  533. return value
  534. multiplier *= 128
  535. except OSError:
  536. return None
  537. return None
  538. def _record_pending_request(self, data: dict, client_id: str) -> None:
  539. """Stash sequence_id → client_id for any nested block with a sequence_id.
  540. Slicer commands typically wrap their seq id in ``{"print": {...}}`` or
  541. ``{"info": {...}}`` / ``{"system": {...}}`` etc. Walks top-level dict
  542. values once to find the seq id; if absent (some commands omit it) we
  543. skip — the response will fall through to broadcast which is fine for
  544. unsolicited pushes.
  545. """
  546. for block in data.values():
  547. if isinstance(block, dict):
  548. seq = block.get("sequence_id")
  549. if seq is not None:
  550. key = str(seq)
  551. # Evict oldest entry when over the cap. Python dicts
  552. # preserve insertion order so iter(self._pending_requests)
  553. # yields the oldest key first.
  554. while len(self._pending_requests) >= _PENDING_REQUEST_MAX_ENTRIES:
  555. oldest = next(iter(self._pending_requests))
  556. self._pending_requests.pop(oldest, None)
  557. self._pending_requests[key] = client_id
  558. return
  559. def _lookup_pending_request_client(self, payload: bytes) -> str | None:
  560. """Parse a bridge-forwarded MQTT payload and return the originating
  561. client_id if its sequence_id was recorded.
  562. Returns ``None`` for printer-initiated pushes (no recorded seq id) so
  563. push_raw_to_clients falls back to broadcast — required for push_status
  564. and the other unsolicited pushes that every connected slicer expects.
  565. """
  566. try:
  567. parsed = json.loads(payload)
  568. except (ValueError, TypeError):
  569. return None
  570. if not isinstance(parsed, dict):
  571. return None
  572. for block in parsed.values():
  573. if isinstance(block, dict):
  574. seq = block.get("sequence_id")
  575. if seq is not None:
  576. return self._pending_requests.pop(str(seq), None)
  577. return None
  578. def _is_auth_rate_limited(self, source_ip: str) -> bool:
  579. """Return True if ``source_ip`` has hit the per-IP failure cap.
  580. Prunes timestamps older than the window so the dict doesn't grow
  581. unbounded. Uses ``time.monotonic()`` for a wall-clock-jump-immune
  582. clock that's safe to call from any context (sync or async).
  583. """
  584. import time as _time
  585. now = _time.monotonic()
  586. window_start = now - _AUTH_RATE_LIMIT_WINDOW_SECONDS
  587. recent = [t for t in self._auth_failures.get(source_ip, []) if t >= window_start]
  588. if recent:
  589. self._auth_failures[source_ip] = recent
  590. else:
  591. self._auth_failures.pop(source_ip, None)
  592. return len(recent) >= _AUTH_RATE_LIMIT_MAX_ATTEMPTS
  593. def _record_auth_failure(self, source_ip: str) -> None:
  594. """Append a timestamp for ``source_ip``'s failed auth attempt."""
  595. import time as _time
  596. now = _time.monotonic()
  597. self._auth_failures.setdefault(source_ip, []).append(now)
  598. def _release_auth_attempt(self, source_ip: str) -> None:
  599. """Take back the one attempt counted ahead of a sign-in that succeeded.
  600. Not the failures before it: a good sign-in wiping them let a user with
  601. a code of their own guess others' codes four at a time (#1430).
  602. """
  603. attempts = self._auth_failures.get(source_ip)
  604. if attempts:
  605. attempts.pop()
  606. if not attempts:
  607. self._auth_failures.pop(source_ip, None)
  608. async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> tuple[bool, int]:
  609. """Handle MQTT CONNECT packet.
  610. Returns ``(authenticated, keep_alive_seconds)`` — the second element
  611. is the value the client advertised in CONNECT, so the caller's
  612. read-loop can honour it instead of the hardcoded default. ``0``
  613. means the client opted out of keepalive (#1548).
  614. """
  615. try:
  616. # Parse CONNECT packet
  617. # Skip protocol name length and name
  618. idx = 0
  619. proto_len = (payload[idx] << 8) | payload[idx + 1]
  620. idx += 2 + proto_len
  621. # Skip protocol level and connect flags
  622. # connect_flags = payload[idx + 1]
  623. idx += 2
  624. # Keepalive (2-byte big-endian, seconds). Honoured by the read
  625. # loop in `_handle_client` per MQTT spec §3.1.2.10 / §4.4 —
  626. # before #1548 we ignored this and used a hardcoded 60 s, which
  627. # closed OrcaSlicer's idle connection at exactly the negotiated
  628. # keepalive boundary instead of the spec-mandated 1.5×.
  629. keep_alive = (payload[idx] << 8) | payload[idx + 1]
  630. idx += 2
  631. # Read client ID
  632. client_id_len = (payload[idx] << 8) | payload[idx + 1]
  633. idx += 2
  634. # client_id = payload[idx : idx + client_id_len].decode("utf-8")
  635. idx += client_id_len
  636. # Read username
  637. username_len = (payload[idx] << 8) | payload[idx + 1]
  638. idx += 2
  639. username = payload[idx : idx + username_len].decode("utf-8")
  640. idx += username_len
  641. # Read password
  642. password_len = (payload[idx] << 8) | payload[idx + 1]
  643. idx += 2
  644. password = payload[idx : idx + password_len].decode("utf-8")
  645. # Authenticate. ``hmac.compare_digest`` is constant-time to keep
  646. # the auth check from leaking the access code via response timing
  647. # under network jitter — LAN-only threat is marginal, but it's
  648. # the standard fix and costs nothing.
  649. authenticated = username == "bblp" and hmac.compare_digest(password, self.access_code)
  650. if not authenticated and username == "bblp" and self.authenticate_user is not None:
  651. user_id = await self.authenticate_user(password)
  652. if user_id is not None:
  653. authenticated = True
  654. self._connection_users[writer] = user_id
  655. if authenticated:
  656. # Send CONNACK with success
  657. writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
  658. await writer.drain()
  659. logger.info("%sMQTT client authenticated successfully", self._log_prefix)
  660. # Send immediate status report after auth - slicer expects this
  661. await self._send_status_report(writer)
  662. return True, keep_alive
  663. else:
  664. # Send CONNACK with auth failure
  665. writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized
  666. await writer.drain()
  667. logger.warning("%sMQTT auth failed for user '%s' (access code mismatch)", self._log_prefix, username)
  668. return False, 0
  669. except (IndexError, ValueError) as e:
  670. logger.debug("MQTT CONNECT parse error: %s", e)
  671. # Send CONNACK with error
  672. writer.write(bytes([0x20, 0x02, 0x00, 0x02])) # Protocol error
  673. await writer.drain()
  674. return False, 0
  675. async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
  676. """Handle MQTT SUBSCRIBE packet."""
  677. try:
  678. # Parse packet ID
  679. packet_id = (payload[0] << 8) | payload[1]
  680. # Parse topic filters (just acknowledge them)
  681. idx = 2
  682. granted_qos = []
  683. learned_serial: str | None = None
  684. while idx < len(payload):
  685. topic_len = (payload[idx] << 8) | payload[idx + 1]
  686. idx += 2
  687. topic = payload[idx : idx + topic_len].decode("utf-8")
  688. idx += topic_len
  689. requested_qos = payload[idx]
  690. idx += 1
  691. logger.info("%sMQTT subscribe: %s QoS=%s", self._log_prefix, topic, requested_qos)
  692. granted_qos.append(min(requested_qos, 1)) # Grant up to QoS 1
  693. # Remember the serial the slicer is listening on so status/version
  694. # responses go to a topic it actually subscribed to.
  695. if learned_serial is None:
  696. extracted = self._extract_serial_from_topic(topic)
  697. if extracted:
  698. learned_serial = extracted
  699. if learned_serial and learned_serial != self._client_serials.get(client_id):
  700. if learned_serial != self.serial:
  701. logger.info(
  702. "%sMQTT client subscribed with serial %s (VP serial is %s) — adapting responses",
  703. self._log_prefix,
  704. learned_serial,
  705. self.serial,
  706. )
  707. self._client_serials[client_id] = learned_serial
  708. # Send SUBACK
  709. suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF])
  710. suback += bytes(granted_qos)
  711. writer.write(suback)
  712. await writer.drain()
  713. # Send initial status report after subscribe on the client's subscribed topic
  714. await self._send_status_report(writer, serial=self._client_serials.get(client_id, self.serial))
  715. except (IndexError, ValueError, OSError) as e:
  716. logger.debug("MQTT SUBSCRIBE error: %s", e)
  717. async def _send_status_report(
  718. self, writer: asyncio.StreamWriter, serial: str | None = None, log_event: bool = True
  719. ) -> None:
  720. """Send a status report to the slicer after connection.
  721. When a bridge is active and has cached the real printer's latest
  722. push_status, send a copy of the real push with only the upload-state-
  723. machine fields we own (gcode_state, gcode_file, prepare_percent,
  724. subtask_name) overridden. BambuStudio's Send pre-flight checks the
  725. push_status shape against what it expects from the printer model, and
  726. the synthetic stub introduced fields the real H2D doesn't have (storage,
  727. the wrong chamber_temper shape, etc.) which trip the check.
  728. While the target printer is actually printing and the VP has no upload
  729. handshake of its own in flight, the live-progress fields are mirrored
  730. through instead of zeroed, under a forced gcode_state=FINISH — see
  731. ``_mirroring_live_progress`` for why that specific state (#1887).
  732. """
  733. try:
  734. self._sequence_id += 1
  735. cached = self._bridge.get_latest_print_state() if self._bridge is not None else None
  736. if isinstance(cached, dict):
  737. # Real-printer-shaped response. Copy the cache, then replace the
  738. # protocol / upload-state fields with values under our control.
  739. # Deep copy — current mutations are top-level only, but a future
  740. # override that writes into a nested dict (e.g. ``online``,
  741. # ``upgrade_state``, ``ipcam``) would otherwise corrupt the
  742. # bridge cache and be read by every subsequent subscriber until
  743. # the next real-printer push lands. Cost is one allocation per
  744. # status report; the cached dict is already short-lived.
  745. print_block = copy.deepcopy(cached)
  746. print_block["sequence_id"] = str(self._sequence_id)
  747. print_block["command"] = "push_status"
  748. print_block["msg"] = 0
  749. mirroring = self._mirroring_live_progress(cached)
  750. if mirroring:
  751. # gcode_file / subtask_name / the progress fields stay as the
  752. # printer reported them — the slicer renders what is really
  753. # on the bed. FINISH keeps the Send button enabled.
  754. print_block["gcode_state"] = "FINISH"
  755. print_block["gcode_file_prepare_percent"] = "100"
  756. print_block.setdefault("subtask_name", "")
  757. else:
  758. print_block["gcode_state"] = self._gcode_state
  759. print_block["gcode_file"] = self._current_file
  760. print_block["gcode_file_prepare_percent"] = self._prepare_percent
  761. if self._current_file:
  762. print_block["subtask_name"] = self._current_file.replace(".3mf", "")
  763. else:
  764. # Don't override real subtask_name with empty if no upload pending.
  765. print_block.setdefault("subtask_name", "")
  766. # Storage-availability indicators the slicer's "Send" pre-flight reads
  767. # (#1228). P1S/A1-class firmware doesn't always include these in
  768. # push_status (no SD card inserted, older field shapes), and BambuStudio
  769. # rejects the send pre-flight with the generic "storage needs to be
  770. # inserted before send to printer" error before even attempting FTP.
  771. # For VP usage the slicer uploads via FTPS to Bambuddy's filesystem —
  772. # the printer's actual SD/storage state is irrelevant on that path.
  773. # Force "available" indicators so the pre-flight passes regardless of
  774. # what the real printer reports. Restores the 0.2.3.2 synthetic-stub
  775. # behaviour for these fields without losing the live AMS / k-profile /
  776. # camera mirror cached-as-base provides.
  777. print_block["home_flag"] = print_block.get("home_flag", 0) | 0x100 # bit 8 = HAS_SDCARD_NORMAL
  778. print_block["sdcard"] = True
  779. print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000})
  780. # Live-progress fields the slicer's Send pre-flight reads
  781. # (#1558). When the VP reports itself idle, these have to read
  782. # idle too: the cached push_status carries the printer's real
  783. # values, and a report that says gcode_state=IDLE while
  784. # mc_percent>0 / stg_cur>0 is internally contradictory — the
  785. # slicer takes it as busy and blocks Send. Same shape as #1228
  786. # (storage). When the mirror is on, gcode_state=FINISH agrees
  787. # with a non-zero progress set, so they pass through instead.
  788. if not mirroring:
  789. print_block["mc_print_stage"] = ""
  790. print_block["mc_percent"] = 0
  791. print_block["mc_remaining_time"] = 0
  792. print_block["stg"] = []
  793. print_block["stg_cur"] = 0
  794. print_block["layer_num"] = 0
  795. print_block["total_layer_num"] = 0
  796. # print_error is never mirrored: StatusPanel raises a modal error
  797. # dialog for a non-zero code, and the VP is not the machine that
  798. # threw it — the user's own printer card in Bambuddy reports the
  799. # fault. Zero it in both branches.
  800. print_block["print_error"] = 0
  801. status = {"print": print_block}
  802. dump_wire(self.vp_name, "out", status)
  803. await self._publish_to_report(writer, status, serial or self.serial, log_event=log_event)
  804. return
  805. # No bridge / no cache yet — fall back to the synthetic stub.
  806. status = {
  807. "print": {
  808. "sequence_id": str(self._sequence_id),
  809. "command": "push_status",
  810. "msg": 0,
  811. "gcode_state": self._gcode_state,
  812. "gcode_file": self._current_file,
  813. "gcode_file_prepare_percent": self._prepare_percent,
  814. "subtask_name": self._current_file.replace(".3mf", "") if self._current_file else "",
  815. "mc_print_stage": "",
  816. "mc_percent": 0,
  817. "mc_remaining_time": 0,
  818. "wifi_signal": "-44dBm",
  819. "print_error": 0,
  820. "print_type": "",
  821. "bed_temper": 25.0,
  822. "bed_target_temper": 0.0,
  823. "nozzle_temper": 25.0,
  824. "nozzle_target_temper": 0.0,
  825. "chamber_temper": 25.0,
  826. "cooling_fan_speed": "0",
  827. "big_fan1_speed": "0",
  828. "big_fan2_speed": "0",
  829. "heatbreak_fan_speed": "0",
  830. "spd_lvl": 1,
  831. "spd_mag": 100,
  832. "stg": [],
  833. "stg_cur": 0,
  834. "layer_num": 0,
  835. "total_layer_num": 0,
  836. "home_flag": 256, # Bit 8 = SD card present (HAS_SDCARD_NORMAL)
  837. "hw_switch_state": 0,
  838. "online": {"ahb": False, "rfid": False, "version": 7},
  839. "ams_status": 0,
  840. "sdcard": True,
  841. "storage": {"free": 1000000000, "total": 32000000000},
  842. "upgrade_state": {
  843. "sequence_id": 0,
  844. "progress": "",
  845. "status": "",
  846. "consistency_request": False,
  847. "dis_state": 0,
  848. "err_code": 0,
  849. "force_upgrade": False,
  850. "message": "",
  851. "module": "",
  852. "new_version_state": 2,
  853. "new_ver_list": [],
  854. "ota_new_version_number": "",
  855. "ahb_new_version_number": "",
  856. },
  857. "ipcam": {
  858. "ipcam_dev": "1",
  859. "ipcam_record": "enable",
  860. "timelapse": "disable",
  861. "resolution": "1080p",
  862. "mode_bits": 0,
  863. },
  864. "xcam": {
  865. "allow_skip_parts": False,
  866. "buildplate_marker_detector": True,
  867. "first_layer_inspector": True,
  868. "halt_print_sensitivity": "medium",
  869. "print_halt": True,
  870. "printing_monitor": True,
  871. "spaghetti_detector": True,
  872. },
  873. "lights_report": [{"node": "chamber_light", "mode": "on"}],
  874. "nozzle_diameter": "0.4",
  875. "nozzle_type": "hardened_steel",
  876. }
  877. }
  878. await self._publish_to_report(writer, status, serial or self.serial, log_event=log_event)
  879. except OSError as e:
  880. logger.error("Failed to send status report: %s", e)
  881. async def _send_version_response(
  882. self, writer: asyncio.StreamWriter, sequence_id: str, serial: str | None = None
  883. ) -> None:
  884. """Send version info response to the slicer."""
  885. try:
  886. product_name = MODEL_PRODUCT_NAMES.get(self.model, self.model or "X1 Carbon")
  887. # The serial is embedded inside the module[].sn fields *and* used as
  888. # the report topic. Use the client's effective serial so the slicer
  889. # sees internal/topic consistency even when it differs from self.serial.
  890. serial = serial or self.serial
  891. # Build version response matching OrcaSlicer expectations
  892. # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
  893. version_info = {
  894. "info": {
  895. "command": "get_version",
  896. "sequence_id": sequence_id,
  897. "module": [
  898. {
  899. "name": "ota",
  900. "product_name": product_name,
  901. "sw_ver": "01.07.00.00",
  902. "sw_new_ver": "",
  903. "hw_ver": "OTA",
  904. "sn": serial,
  905. "flag": 0,
  906. },
  907. {
  908. "name": "esp32",
  909. "product_name": product_name,
  910. "sw_ver": "01.07.22.25",
  911. "sw_new_ver": "",
  912. "hw_ver": "AP05",
  913. "sn": serial,
  914. "flag": 0,
  915. },
  916. {
  917. "name": "rv1126",
  918. "product_name": product_name,
  919. "sw_ver": "00.00.27.38",
  920. "sw_new_ver": "",
  921. "hw_ver": "AP05",
  922. "sn": serial,
  923. "flag": 0,
  924. },
  925. {
  926. "name": "th",
  927. "product_name": product_name,
  928. "sw_ver": "00.00.04.00",
  929. "sw_new_ver": "",
  930. "hw_ver": "TH07",
  931. "sn": serial,
  932. "flag": 0,
  933. },
  934. {
  935. "name": "mc",
  936. "product_name": product_name,
  937. "sw_ver": "00.00.10.00",
  938. "sw_new_ver": "",
  939. "hw_ver": "MC07",
  940. "sn": serial,
  941. "flag": 0,
  942. },
  943. ],
  944. }
  945. }
  946. # Overlay real version modules from the bridge cache when available
  947. # (specifically the AMS modules ams/0, n3f/0, n3s/128 etc. that
  948. # BambuStudio's Prepare tab uses to identify AMS hardware — without
  949. # them every AMS unit shows as "unknown" in the Prepare panel).
  950. if self._bridge is not None:
  951. cached_modules = self._bridge.get_latest_version_modules()
  952. if isinstance(cached_modules, list) and cached_modules:
  953. version_info["info"]["module"] = cached_modules
  954. await self._publish_to_report(writer, version_info, serial)
  955. logger.info("Sent version response (product_name=%s)", product_name)
  956. except OSError as e:
  957. logger.error("Failed to send version response: %s", e)
  958. def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None:
  959. """Update the gcode state reported to connected slicers.
  960. Called by the manager to reflect FTP upload progress/completion.
  961. """
  962. self._gcode_state = state
  963. self._current_file = filename
  964. self._prepare_percent = prepare_percent
  965. self._state_changed_at = time.monotonic()
  966. def _mirroring_live_progress(self, cached: dict) -> bool:
  967. """True when the report should carry the target printer's live progress.
  968. The slicers gate the Device-tab progress panel and the Send button on
  969. the same predicate (``MachineObject::is_in_printing()``), so the VP
  970. cannot report the printer's real gcode_state without also telling the
  971. slicer it is too busy to accept a job — which is the whole point of a
  972. non-proxy VP, and was the #1558 regression. Reporting FINISH instead
  973. renders the panel (StatusPanel checks ``is_in_printing() ||
  974. print_status == "FINISH"``) and leaves Send enabled, so the mirror is
  975. FINISH plus the printer's real numbers.
  976. Two things suppress it:
  977. * The VP's own upload state machine owns the report while a job is
  978. being handed over (PREPARE), and for a short settle window after —
  979. the slicer only releases its in-flight-job lock once it sees FINISH
  980. carrying the ``subtask_name`` it just uploaded (#1280 / #1658), and
  981. swapping in the printer's filename mid-handshake wedges the send
  982. modal at "Downloading".
  983. * The printer isn't printing, in which case there is no progress to
  984. show and the VP's own state is the honest thing to report.
  985. """
  986. if self._gcode_state == "PREPARE":
  987. return False
  988. if time.monotonic() - self._state_changed_at < _UPLOAD_SETTLE_SECONDS:
  989. return False
  990. return str(cached.get("gcode_state") or "").upper() in _MIRRORED_PRINT_STATES
  991. async def _publish_to_report(
  992. self, writer: asyncio.StreamWriter, payload: dict, serial: str = "", log_event: bool = True
  993. ) -> None:
  994. """Publish a message on the device report topic.
  995. Real Bambu printers wire-format push_status JSON with 4-space indentation
  996. (32254 bytes for an idle H2D push vs 14268 bytes compact). BambuStudio's
  997. Send pre-flight rejects compact JSON — without matching the on-wire
  998. format the slicer never proceeds to FTP upload.
  999. ``log_event=True`` records the publish in ``vp_wire/<vp>_cmd.jsonl``
  1000. under the ``bridge_to_slicer`` direction so #1622-style triages can
  1001. diff the bridge's own outbound replies (info.get_version answer,
  1002. project_file ack, on-demand pushall response) against the real
  1003. printer's ``printer_to_slicer`` forwards. The 1Hz periodic push
  1004. sets ``log_event=False`` because dump_wire's overwrite-snapshot
  1005. already covers cache shape and a per-second JSONL line would dwarf
  1006. the actual command events.
  1007. """
  1008. topic = f"device/{serial or self.serial}/report"
  1009. message = json.dumps(payload, indent=4)
  1010. topic_bytes = topic.encode("utf-8")
  1011. message_bytes = message.encode("utf-8")
  1012. remaining = 2 + len(topic_bytes) + len(message_bytes)
  1013. packet = bytes([0x30]) # PUBLISH, QoS 0
  1014. while remaining > 0:
  1015. byte = remaining % 128
  1016. remaining //= 128
  1017. if remaining > 0:
  1018. byte |= 0x80
  1019. packet += bytes([byte])
  1020. packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
  1021. packet += topic_bytes
  1022. packet += message_bytes
  1023. if log_event:
  1024. # Env-flagged command trace (#1622): captures bridge-synthesised
  1025. # replies (info.get_version, project_file ack, on-demand pushall
  1026. # response) AFTER the payload is finalised but before it hits
  1027. # the wire — so the cmd.jsonl reflects exactly what the slicer
  1028. # parses. Pair with the slicer_to_bridge events from
  1029. # _handle_publish and the printer_to_slicer fan-outs from
  1030. # mqtt_bridge.
  1031. append_event(self.vp_name, "bridge_to_slicer", topic, payload)
  1032. writer.write(packet)
  1033. # Timeout the drain to prevent blocking the event loop if the
  1034. # MQTT client stops reading (e.g. slicer busy with FTP upload,
  1035. # macOS suspends the client mid-session — #1872).
  1036. #
  1037. # On timeout, close the writer and raise BrokenPipeError so the
  1038. # push-loop's ``except OSError`` at ``_periodic_status_push``
  1039. # evicts the client from ``self._clients`` on this same tick.
  1040. # Before this, timeouts logged at DEBUG and returned silently,
  1041. # so the zombie writer sat in ``self._clients`` until SO_KEEPALIVE
  1042. # detected the dead peer (~2 h on Linux defaults). That kept the
  1043. # push loop spending 5 s per iteration on the stalled client and
  1044. # left the slicer's UI unaware the session was gone.
  1045. try:
  1046. await asyncio.wait_for(writer.drain(), timeout=5)
  1047. except TimeoutError as e:
  1048. logger.info(
  1049. "%sMQTT drain timeout for %s — closing stalled writer",
  1050. self._log_prefix,
  1051. topic,
  1052. )
  1053. try:
  1054. writer.close()
  1055. except Exception:
  1056. pass # best-effort — writer may already be broken
  1057. raise BrokenPipeError(f"drain timeout on {topic}") from e
  1058. async def _send_print_response(
  1059. self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None
  1060. ) -> None:
  1061. """Send project_file acknowledgment matching real Bambu printer behavior."""
  1062. # Update state so periodic status pushes reflect preparation. Goes
  1063. # through set_gcode_state so the live-progress mirror sees the
  1064. # transition and holds off until the upload handshake has settled.
  1065. self.set_gcode_state("PREPARE", filename=filename, prepare_percent="0")
  1066. try:
  1067. # Send command acknowledgment — slicer expects to see
  1068. # command: "project_file" echoed back before starting FTP upload
  1069. subtask_name = filename.replace(".3mf", "") if filename else ""
  1070. response = {
  1071. "print": {
  1072. "command": "project_file",
  1073. "sequence_id": sequence_id,
  1074. "param": "Metadata/plate_1.gcode",
  1075. "subtask_name": subtask_name,
  1076. "gcode_state": "PREPARE",
  1077. "gcode_file": filename,
  1078. "gcode_file_prepare_percent": "0",
  1079. "result": "SUCCESS",
  1080. "msg": 0,
  1081. }
  1082. }
  1083. await self._publish_to_report(writer, response, serial or self.serial)
  1084. logger.info("Sent project_file acknowledgment for %s", filename)
  1085. except OSError as e:
  1086. logger.error("Failed to send print response: %s", e)
  1087. async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
  1088. """Handle MQTT PUBLISH packet."""
  1089. try:
  1090. # Parse topic
  1091. idx = 0
  1092. topic_len = (payload[idx] << 8) | payload[idx + 1]
  1093. idx += 2
  1094. topic = payload[idx : idx + topic_len].decode("utf-8")
  1095. idx += topic_len
  1096. # Check for packet ID (QoS > 0)
  1097. qos = (header & 0x06) >> 1
  1098. if qos > 0:
  1099. # packet_id = (payload[idx] << 8) | payload[idx + 1]
  1100. idx += 2
  1101. # Parse message
  1102. message = payload[idx:].decode("utf-8")
  1103. logger.info("MQTT publish to %s: %s...", topic, message[:100])
  1104. # Only handle publishes on *some* device/.../request topic. The
  1105. # serial is taken from the topic rather than compared against
  1106. # self.serial: the client is already authenticated via the access
  1107. # code, and Orca/BambuStudio may have a cached serial that differs
  1108. # from the VP's computed self.serial (#927). Use the topic's serial
  1109. # for all responses so they land on the topic the slicer subscribed
  1110. # to.
  1111. if not topic.startswith("device/") or "/request" not in topic:
  1112. return
  1113. client_serial = self._extract_serial_from_topic(topic) or self.serial
  1114. if client_serial and client_serial != self._client_serials.get(client_id):
  1115. if client_serial != self.serial:
  1116. logger.info(
  1117. "%sMQTT client publishing with serial %s (VP serial is %s) — adapting responses",
  1118. self._log_prefix,
  1119. client_serial,
  1120. self.serial,
  1121. )
  1122. self._client_serials[client_id] = client_serial
  1123. try:
  1124. # Some slicer builds (observed with OrcaSlicer on Linux, #927)
  1125. # include the C-string null terminator in the MQTT payload
  1126. # length, so the decoded message ends with \x00. Real brokers
  1127. # pass the bytes through; strict json.loads raises "Extra data"
  1128. # and every pushall/get_version/project_file silently dropped.
  1129. data = json.loads(message.rstrip("\x00 \r\n\t"))
  1130. except json.JSONDecodeError as e:
  1131. logger.debug(
  1132. "MQTT publish JSON decode failed: %s (payload=%r)",
  1133. e,
  1134. message[:200],
  1135. )
  1136. return
  1137. # The line above stops at 100 characters, before the tray_id of a
  1138. # K-profile command: how BambuStudio numbers an A2L or AMS-HT tray,
  1139. # which support bundles are the only source for. These carry no
  1140. # credentials, so they are logged whole.
  1141. print_command = data.get("print") if isinstance(data, dict) else None
  1142. if isinstance(print_command, dict) and print_command.get("command") in _COMMANDS_LOGGED_WHOLE:
  1143. logger.info(
  1144. "MQTT %s from slicer: %s", print_command["command"], json.dumps(print_command, sort_keys=True)
  1145. )
  1146. # Env-flagged command trace (#1622): every slicer-originated publish
  1147. # gets a line in vp_wire/<vp>_cmd.jsonl alongside the printer-side
  1148. # responses captured in mqtt_bridge. Off by default.
  1149. append_event(self.vp_name, "slicer_to_bridge", topic, data)
  1150. # The synthetic flow below is the original (pre-bridge) behaviour and is
  1151. # what the proven-working FTP "Send" depends on. Do NOT replace any
  1152. # synthetic response with a forward — only ADD forwarding alongside,
  1153. # at the bottom, for commands the synthetic flow doesn't handle
  1154. # (AMS write / xcam / system / etc., which need to actually reach
  1155. # the real printer).
  1156. handled_locally = False
  1157. # Handle pushing command (status request)
  1158. if "pushing" in data:
  1159. pushing_data = data["pushing"]
  1160. command = pushing_data.get("command", "")
  1161. logger.info("MQTT pushing command: %s", command)
  1162. if command == "pushall":
  1163. logger.info("Sending status report in response to pushall")
  1164. await self._send_status_report(writer, serial=client_serial)
  1165. handled_locally = True
  1166. elif command == "start":
  1167. logger.info("Starting status push stream")
  1168. await self._send_status_report(writer, serial=client_serial)
  1169. handled_locally = True
  1170. # Handle info commands (get_version, etc.)
  1171. if "info" in data:
  1172. info_data = data["info"]
  1173. command = info_data.get("command", "")
  1174. sequence_id = info_data.get("sequence_id", "0")
  1175. logger.info("MQTT info command: %s", command)
  1176. if command == "get_version":
  1177. await self._send_version_response(writer, sequence_id, serial=client_serial)
  1178. handled_locally = True
  1179. # Handle print commands
  1180. if "print" in data:
  1181. print_data = data["print"]
  1182. command = print_data.get("command", "")
  1183. filename = print_data.get("subtask_name", "")
  1184. sequence_id = print_data.get("sequence_id", "0")
  1185. logger.info("MQTT print command: %s for %s", command, filename)
  1186. if command in ("project_file", "gcode_file"):
  1187. # File lives on Bambuddy, not the printer — synthetic only.
  1188. file_3mf = print_data.get("file", filename)
  1189. await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
  1190. if self.on_print_command:
  1191. # `filename` is the slicer's `subtask_name` (bare model
  1192. # name, no extension). Pass it through verbatim — the
  1193. # `_schedule_finish_release` chain echoes it back as
  1194. # gcode_file + subtask_name in push_status, and the
  1195. # slicer matches against its own subtask_name there.
  1196. # The FTP filename (with extension) is in print_data
  1197. # under "file" for the queue-stash side to use as its
  1198. # own key matching `_add_to_print_queue`'s lookup.
  1199. await self._notify_print_command(filename, print_data)
  1200. handled_locally = True
  1201. # Forward anything the synthetic flow didn't handle to the real
  1202. # printer. AMS load / dry / xcam / system / extrusion_cali_get etc.
  1203. user_id = self._connection_users.get(writer)
  1204. if (
  1205. not handled_locally
  1206. and self._bridge is not None
  1207. and self._bridge.is_active
  1208. and user_id is not None
  1209. and (self.authorize_control is None or not await self.authorize_control(user_id))
  1210. ):
  1211. logger.info(
  1212. "%sMQTT command from user %s not passed to the printer: no printers:control",
  1213. self._log_prefix,
  1214. user_id,
  1215. )
  1216. handled_locally = True
  1217. if not handled_locally and self._bridge is not None and self._bridge.is_active:
  1218. # Remember which client originated this command so the
  1219. # printer's response goes back only to them (not fanned
  1220. # out to every connected slicer).
  1221. self._record_pending_request(data, client_id)
  1222. self._bridge.forward_to_printer(data)
  1223. except (IndexError, ValueError, OSError) as e:
  1224. logger.debug("MQTT PUBLISH error: %s", e)
  1225. async def _notify_print_command(self, filename: str, data: dict) -> None:
  1226. """Notify callback of print command."""
  1227. if self.on_print_command:
  1228. try:
  1229. result = self.on_print_command(filename, data)
  1230. if asyncio.iscoroutine(result):
  1231. await result
  1232. except Exception as e:
  1233. logger.error("Print command callback error: %s", e)