printer_diagnostic.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """Connection diagnostic for Bambu printers.
  2. Runs the checks a maintainer performs by hand when triaging a
  3. "printer won't connect / won't print" report — port reachability, LAN
  4. developer mode, Docker network mode, subnet match, and MQTT credentials —
  5. so users can self-diagnose setup problems instead of opening an issue.
  6. See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
  7. user-side setup errors clustered on exactly these causes.
  8. """
  9. import asyncio
  10. import ipaddress
  11. import logging
  12. import socket
  13. from backend.app.models.printer import Printer
  14. from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
  15. from backend.app.services.discovery import is_running_in_docker
  16. from backend.app.services.printer_manager import printer_manager
  17. logger = logging.getLogger(__name__)
  18. # Bambu LAN-mode ports.
  19. PORT_MQTT = 8883 # MQTT over TLS — control + status. Connection-critical.
  20. PORT_FTPS = 990 # FTPS — file upload; required to send prints.
  21. PORT_RTSPS = 322 # RTSPS — camera stream; optional.
  22. _PORT_PROBE_TIMEOUT = 3.0
  23. # Default seconds the `printer_publishing` check will wait for the first
  24. # report-topic message before declaring fail. Bambu printers in idle publish
  25. # push_status every few seconds; 10s catches healthy bridges with margin while
  26. # staying short enough that the spinner-with-countdown UX stays acceptable.
  27. # The check exits the moment a message arrives, so the typical wall-clock is
  28. # 1–2s, not the full 10. Passed as ``wait_for_publish_seconds`` per call so
  29. # the support-package code path can skip the wait entirely (defaults to 0).
  30. PUBLISH_WAIT_DEFAULT = 10.0
  31. _PUBLISH_POLL_INTERVAL = 0.5
  32. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  33. """Test TCP connectivity to ip:port. Returns True if reachable."""
  34. try:
  35. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  36. writer.close()
  37. try:
  38. await writer.wait_closed()
  39. except Exception:
  40. pass
  41. return True
  42. except Exception:
  43. return False
  44. def _detect_docker_network_mode() -> str:
  45. """Detect Docker network mode.
  46. In host mode the container shares the host network namespace, so Docker
  47. infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
  48. mode the container only sees its own eth0.
  49. """
  50. try:
  51. for _idx, name in socket.if_nameindex():
  52. if name.startswith(("docker", "br-", "veth", "virbr")):
  53. return "host"
  54. except Exception:
  55. pass
  56. return "bridge"
  57. def _get_host_ip() -> str | None:
  58. """Best-effort IPv4 address the Bambuddy host routes from."""
  59. try:
  60. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  61. try:
  62. # No packets are sent; this just picks the routing-table source IP.
  63. s.connect(("10.255.255.255", 1))
  64. return s.getsockname()[0]
  65. finally:
  66. s.close()
  67. except Exception:
  68. return None
  69. def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
  70. """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
  71. try:
  72. addr_a = ipaddress.ip_address(ip_a)
  73. addr_b = ipaddress.ip_address(ip_b)
  74. except ValueError:
  75. return None
  76. if addr_a.version != 4 or addr_b.version != 4:
  77. return None
  78. net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
  79. net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
  80. return net_a == net_b
  81. async def run_connection_diagnostic(
  82. ip_address: str,
  83. *,
  84. printer: Printer | None = None,
  85. serial_number: str | None = None,
  86. access_code: str | None = None,
  87. wait_for_publish_seconds: float = 0.0,
  88. ) -> PrinterDiagnosticResult:
  89. """Run connection checks for a printer.
  90. Works for an existing saved printer (pass ``printer``) and for the
  91. pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
  92. Each check carries a stable ``id`` and a ``status`` of
  93. pass / fail / warn / skip; the frontend renders the human-readable
  94. title and fix text (localized) keyed on that id + status.
  95. """
  96. checks: list[DiagnosticCheck] = []
  97. # --- Port reachability (probed in parallel) ---
  98. mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
  99. _check_port(ip_address, PORT_MQTT),
  100. _check_port(ip_address, PORT_FTPS),
  101. _check_port(ip_address, PORT_RTSPS),
  102. )
  103. # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
  104. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
  105. checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
  106. checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
  107. # --- Docker network mode ---
  108. network_mode: str | None = None
  109. if is_running_in_docker():
  110. network_mode = _detect_docker_network_mode()
  111. checks.append(
  112. DiagnosticCheck(
  113. id="network_mode",
  114. status="pass" if network_mode == "host" else "warn",
  115. params={"mode": network_mode},
  116. )
  117. )
  118. else:
  119. checks.append(DiagnosticCheck(id="network_mode", status="skip"))
  120. # --- Subnet match ---
  121. # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
  122. # so the comparison is meaningless and the network_mode check already covers it.
  123. if network_mode == "bridge":
  124. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  125. else:
  126. host_ip = _get_host_ip()
  127. same = _same_subnet(ip_address, host_ip) if host_ip else None
  128. if same is None:
  129. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  130. else:
  131. checks.append(
  132. DiagnosticCheck(
  133. id="subnet",
  134. status="pass" if same else "warn",
  135. params={"printer_ip": ip_address, "host_ip": host_ip},
  136. )
  137. )
  138. # --- External storage (printer-side "Store sent files on external storage") ---
  139. # Install step 4. The setting has two variants depending on
  140. # firmware/slicer combo: on newer firmware the toggle lives on the
  141. # printer (P2S 01.02 / BambuStudio 2.6+), on older versions it's
  142. # purely a slicer-side preference.
  143. #
  144. # For the printer-side variant, `home_flag` bit 11 is pushed on every
  145. # status report and parsed into state.store_to_sdcard (bambu_mqtt.py
  146. # line 153). That's the signal here — instant, no FTP I/O.
  147. #
  148. # For the slicer-side variant, the printer never hears about it and
  149. # this check will pass even when the user is missing step 4. That gap
  150. # is covered separately by the "no_3mf_available" archive-fallback
  151. # banner. An FTP upload-and-verify probe was tried and rejected — the
  152. # /cache directory is always writable from Bambuddy regardless of
  153. # either toggle, so the probe always passes and detects nothing.
  154. state = printer_manager.get_status(printer.id) if printer else None
  155. if state is None or not state.connected:
  156. checks.append(DiagnosticCheck(id="external_storage", status="skip"))
  157. elif getattr(state, "store_to_sdcard", None) is True:
  158. checks.append(DiagnosticCheck(id="external_storage", status="pass"))
  159. elif getattr(state, "store_to_sdcard", None) is False:
  160. checks.append(DiagnosticCheck(id="external_storage", status="fail"))
  161. else:
  162. # State exists but the field was never populated — skip rather than
  163. # report a false fail.
  164. checks.append(DiagnosticCheck(id="external_storage", status="skip"))
  165. # --- MQTT credentials / connection ---
  166. if not mqtt_ok:
  167. # Can't reach the broker at all — the port check already reported it.
  168. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  169. elif serial_number and access_code:
  170. # Pre-add flow: actively probe with the credentials the user entered.
  171. try:
  172. result = await printer_manager.test_connection(
  173. ip_address=ip_address,
  174. serial_number=serial_number,
  175. access_code=access_code,
  176. )
  177. checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
  178. except Exception:
  179. logger.debug("test_connection failed during diagnostic", exc_info=True)
  180. checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
  181. elif state is not None:
  182. # Existing printer: trust the live MQTT state rather than opening a
  183. # second connection (Bambu printers tolerate few concurrent sessions).
  184. checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
  185. else:
  186. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  187. # --- LAN developer mode (only readable over a live MQTT connection) ---
  188. if state is not None and state.connected:
  189. if state.developer_mode is True:
  190. dev_status = "pass"
  191. elif state.developer_mode is False:
  192. dev_status = "fail"
  193. else:
  194. dev_status = "skip"
  195. checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
  196. else:
  197. checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
  198. # --- Printer is actually publishing on its report topic ---
  199. # The mqtt_auth check above only proves TCP + TLS + auth + SUBSCRIBE
  200. # succeed. A printer with a wrong-cased serial — or one that simply isn't
  201. # publishing for some other reason — still passes mqtt_auth because the
  202. # broker accepts the subscription regardless. The user-visible symptom in
  203. # that case is "AMS / K-profiles / custom filaments missing on the slicer
  204. # side": the VP bridge has nothing cached to mirror because no reports
  205. # arrived. #1622 surfaced this: bridge keep-alive timeouts paired with
  206. # the `Connected and subscribed, but the printer has sent zero status
  207. # reports` warning. The check below turns that warning into a structured
  208. # diagnostic result the user can act on without grepping container logs.
  209. #
  210. # If ``_report_messages_since_connect`` is already > 0, we exit
  211. # immediately — the bridge has seen reports. If it's 0 and a wait is
  212. # requested, we poll every PUBLISH_POLL_INTERVAL up to
  213. # ``wait_for_publish_seconds`` so a fresh reconnect (counter reset to 0)
  214. # isn't reported as fail before the printer's first idle push lands.
  215. publishing_params: dict[str, int | float] | None = None
  216. publishing_status = "skip"
  217. if printer is not None and state is not None and state.connected:
  218. client = printer_manager.get_client(printer.id)
  219. if client is not None:
  220. wait_budget = max(wait_for_publish_seconds, 0.0)
  221. if wait_budget > 0:
  222. # Expose the budget so the UI can render a countdown next to
  223. # the spinner — the user knows how long this check might take.
  224. publishing_params = {"max_wait_seconds": wait_budget}
  225. loop = asyncio.get_running_loop()
  226. deadline = loop.time() + wait_budget
  227. while True:
  228. if client.report_messages_since_connect > 0:
  229. publishing_status = "pass"
  230. break
  231. if loop.time() >= deadline:
  232. publishing_status = "fail"
  233. break
  234. await asyncio.sleep(_PUBLISH_POLL_INTERVAL)
  235. checks.append(
  236. DiagnosticCheck(
  237. id="printer_publishing",
  238. status=publishing_status,
  239. params=publishing_params or {},
  240. )
  241. )
  242. statuses = {c.status for c in checks}
  243. if "fail" in statuses:
  244. overall = "problems"
  245. elif "warn" in statuses:
  246. overall = "warnings"
  247. else:
  248. overall = "ok"
  249. return PrinterDiagnosticResult(
  250. printer_id=printer.id if printer else None,
  251. ip_address=ip_address,
  252. overall=overall,
  253. checks=checks,
  254. )