printer_diagnostic.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  24. """Test TCP connectivity to ip:port. Returns True if reachable."""
  25. try:
  26. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  27. writer.close()
  28. try:
  29. await writer.wait_closed()
  30. except Exception:
  31. pass
  32. return True
  33. except Exception:
  34. return False
  35. def _detect_docker_network_mode() -> str:
  36. """Detect Docker network mode.
  37. In host mode the container shares the host network namespace, so Docker
  38. infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
  39. mode the container only sees its own eth0.
  40. """
  41. try:
  42. for _idx, name in socket.if_nameindex():
  43. if name.startswith(("docker", "br-", "veth", "virbr")):
  44. return "host"
  45. except Exception:
  46. pass
  47. return "bridge"
  48. def _get_host_ip() -> str | None:
  49. """Best-effort IPv4 address the Bambuddy host routes from."""
  50. try:
  51. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  52. try:
  53. # No packets are sent; this just picks the routing-table source IP.
  54. s.connect(("10.255.255.255", 1))
  55. return s.getsockname()[0]
  56. finally:
  57. s.close()
  58. except Exception:
  59. return None
  60. def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
  61. """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
  62. try:
  63. addr_a = ipaddress.ip_address(ip_a)
  64. addr_b = ipaddress.ip_address(ip_b)
  65. except ValueError:
  66. return None
  67. if addr_a.version != 4 or addr_b.version != 4:
  68. return None
  69. net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
  70. net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
  71. return net_a == net_b
  72. async def run_connection_diagnostic(
  73. ip_address: str,
  74. *,
  75. printer: Printer | None = None,
  76. serial_number: str | None = None,
  77. access_code: str | None = None,
  78. ) -> PrinterDiagnosticResult:
  79. """Run connection checks for a printer.
  80. Works for an existing saved printer (pass ``printer``) and for the
  81. pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
  82. Each check carries a stable ``id`` and a ``status`` of
  83. pass / fail / warn / skip; the frontend renders the human-readable
  84. title and fix text (localized) keyed on that id + status.
  85. """
  86. checks: list[DiagnosticCheck] = []
  87. # --- Port reachability (probed in parallel) ---
  88. mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
  89. _check_port(ip_address, PORT_MQTT),
  90. _check_port(ip_address, PORT_FTPS),
  91. _check_port(ip_address, PORT_RTSPS),
  92. )
  93. # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
  94. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
  95. checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
  96. checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
  97. # --- Docker network mode ---
  98. network_mode: str | None = None
  99. if is_running_in_docker():
  100. network_mode = _detect_docker_network_mode()
  101. checks.append(
  102. DiagnosticCheck(
  103. id="network_mode",
  104. status="pass" if network_mode == "host" else "warn",
  105. params={"mode": network_mode},
  106. )
  107. )
  108. else:
  109. checks.append(DiagnosticCheck(id="network_mode", status="skip"))
  110. # --- Subnet match ---
  111. # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
  112. # so the comparison is meaningless and the network_mode check already covers it.
  113. if network_mode == "bridge":
  114. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  115. else:
  116. host_ip = _get_host_ip()
  117. same = _same_subnet(ip_address, host_ip) if host_ip else None
  118. if same is None:
  119. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  120. else:
  121. checks.append(
  122. DiagnosticCheck(
  123. id="subnet",
  124. status="pass" if same else "warn",
  125. params={"printer_ip": ip_address, "host_ip": host_ip},
  126. )
  127. )
  128. # --- MQTT credentials / connection ---
  129. state = printer_manager.get_status(printer.id) if printer else None
  130. if not mqtt_ok:
  131. # Can't reach the broker at all — the port check already reported it.
  132. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  133. elif serial_number and access_code:
  134. # Pre-add flow: actively probe with the credentials the user entered.
  135. try:
  136. result = await printer_manager.test_connection(
  137. ip_address=ip_address,
  138. serial_number=serial_number,
  139. access_code=access_code,
  140. )
  141. checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
  142. except Exception:
  143. logger.debug("test_connection failed during diagnostic", exc_info=True)
  144. checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
  145. elif state is not None:
  146. # Existing printer: trust the live MQTT state rather than opening a
  147. # second connection (Bambu printers tolerate few concurrent sessions).
  148. checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
  149. else:
  150. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  151. # --- LAN developer mode (only readable over a live MQTT connection) ---
  152. if state is not None and state.connected:
  153. if state.developer_mode is True:
  154. dev_status = "pass"
  155. elif state.developer_mode is False:
  156. dev_status = "fail"
  157. else:
  158. dev_status = "skip"
  159. checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
  160. else:
  161. checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
  162. statuses = {c.status for c in checks}
  163. if "fail" in statuses:
  164. overall = "problems"
  165. elif "warn" in statuses:
  166. overall = "warnings"
  167. else:
  168. overall = "ok"
  169. return PrinterDiagnosticResult(
  170. printer_id=printer.id if printer else None,
  171. ip_address=ip_address,
  172. overall=overall,
  173. checks=checks,
  174. )