printer_diagnostic.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  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. import ssl
  14. from backend.app.models.printer import Printer
  15. from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
  16. from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
  17. from backend.app.services.camera import get_camera_port
  18. from backend.app.services.discovery import is_running_in_docker
  19. from backend.app.services.ftp_profiles import get_ftp_profile
  20. from backend.app.services.print_storage import REASON_INTERNAL_STORAGE, last_print_storage_verdict
  21. from backend.app.services.printer_manager import printer_manager
  22. from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
  23. logger = logging.getLogger(__name__)
  24. # Bambu LAN-mode ports.
  25. PORT_MQTT = 8883 # MQTT over TLS — control + status. Connection-critical.
  26. PORT_FTPS = 990 # FTPS — file upload; required to send prints.
  27. PORT_RTSPS = 322 # RTSPS — camera stream; optional.
  28. PORT_CHAMBER_IMAGE = 6000 # Chamber image protocol — A1/P1 camera stream; optional.
  29. _PORT_PROBE_TIMEOUT = 3.0
  30. # Default seconds the `printer_publishing` check will wait for the first
  31. # report-topic message before declaring fail. Bambu printers in idle publish
  32. # push_status every few seconds; 10s catches healthy bridges with margin while
  33. # staying short enough that the spinner-with-countdown UX stays acceptable.
  34. # The check exits the moment a message arrives, so the typical wall-clock is
  35. # 1–2s, not the full 10. Passed as ``wait_for_publish_seconds`` per call so
  36. # the support-package code path can skip the wait entirely (defaults to 0).
  37. PUBLISH_WAIT_DEFAULT = 10.0
  38. _PUBLISH_POLL_INTERVAL = 0.5
  39. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  40. """Test TCP connectivity to ip:port. Returns True if reachable."""
  41. try:
  42. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  43. writer.close()
  44. try:
  45. await writer.wait_closed()
  46. except Exception:
  47. pass
  48. return True
  49. except Exception:
  50. return False
  51. # Public alias. The connection watchdog probes the MQTT port before rebuilding a
  52. # client, so it can tell "the printer is switched off" (leave it alone, paho will
  53. # keep retrying) from "the printer is answering but our session is dead" (#2732).
  54. check_port = _check_port
  55. async def _check_ftps_tls(ip: str, model: str | None, timeout: float = _PORT_PROBE_TIMEOUT) -> str:
  56. """Probe port 990 the way the FTP client does, and say how far it got.
  57. Returns ``"ok"``, ``"closed"`` (nothing accepted the TCP connection) or
  58. ``"no_tls"`` (the port accepted the connection but the TLS handshake did
  59. not complete).
  60. A plain TCP probe cannot tell the last two apart, which is exactly how
  61. #2780 hid: the reporter's diagnostic reported port 990 as reachable and
  62. green while every real transfer died in the handshake with
  63. ``WRONG_VERSION_NUMBER``, so archives quietly arrived empty with nothing
  64. on screen to explain it.
  65. The context mirrors :class:`~backend.app.services.bambu_ftp.ImplicitFTP_TLS`
  66. -- including the model's TLS cap -- so a pass here means the FTP client
  67. would also get through. Handshake only; no login is attempted, so this
  68. stays valid for the pre-save Add-Printer flow where no access code exists
  69. yet.
  70. """
  71. context = ssl.create_default_context()
  72. context.check_hostname = False
  73. context.verify_mode = ssl.CERT_NONE
  74. context.minimum_version = ssl.TLSVersion.TLSv1_2
  75. if get_ftp_profile(model).cap_tls_v1_2:
  76. context.maximum_version = ssl.TLSVersion.TLSv1_2
  77. writer = None
  78. try:
  79. _reader, writer = await asyncio.wait_for(
  80. asyncio.open_connection(ip, PORT_FTPS, ssl=context),
  81. timeout=timeout,
  82. )
  83. return "ok"
  84. except ssl.SSLError:
  85. # The socket was accepted and then failed to negotiate TLS. Reaching
  86. # here at all proves something is listening, so this is never "port
  87. # blocked" -- it is the printer's file service in a state no retry
  88. # gets past.
  89. return "no_tls"
  90. except Exception:
  91. return "closed"
  92. finally:
  93. if writer is not None:
  94. writer.close()
  95. try:
  96. await writer.wait_closed()
  97. except Exception:
  98. pass
  99. def _auth_reason_params(reason: str | None) -> dict:
  100. """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
  101. The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
  102. is present and falls back to the plain per-status text otherwise, so an
  103. unknown or absent slug degrades to today's generic wording rather than a
  104. missing string. Only `auth_rejected` currently carries its own message:
  105. that is the one case where the printer positively told us the credentials
  106. were wrong, as opposed to us merely observing that we are not connected.
  107. """
  108. if reason == CONNECT_ERROR_AUTH_REJECTED:
  109. return {"reason": CONNECT_ERROR_AUTH_REJECTED}
  110. return {}
  111. def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
  112. """Return the model-specific camera diagnostic port and display protocol."""
  113. if not printer:
  114. return PORT_RTSPS, "RTSPS"
  115. model = getattr(printer, "model", None)
  116. if not model:
  117. return PORT_RTSPS, "RTSPS"
  118. camera_port = get_camera_port(model)
  119. if camera_port == PORT_CHAMBER_IMAGE:
  120. return camera_port, "Chamber Image"
  121. return camera_port, "RTSPS"
  122. def _detect_docker_network_mode() -> str:
  123. """Detect Docker network mode.
  124. In host mode the container shares the host network namespace, so Docker
  125. infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
  126. mode the container only sees its own eth0.
  127. """
  128. try:
  129. for _idx, name in socket.if_nameindex():
  130. if name.startswith(("docker", "br-", "veth", "virbr")):
  131. return "host"
  132. except Exception:
  133. pass
  134. return "bridge"
  135. def _get_host_ip() -> str | None:
  136. """Best-effort IPv4 address the Bambuddy host routes from."""
  137. try:
  138. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  139. try:
  140. # No packets are sent; this just picks the routing-table source IP.
  141. s.connect(("10.255.255.255", 1))
  142. return s.getsockname()[0]
  143. finally:
  144. s.close()
  145. except Exception:
  146. return None
  147. def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
  148. """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
  149. try:
  150. addr_a = ipaddress.ip_address(ip_a)
  151. addr_b = ipaddress.ip_address(ip_b)
  152. except ValueError:
  153. return None
  154. if addr_a.version != 4 or addr_b.version != 4:
  155. return None
  156. net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
  157. net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
  158. return net_a == net_b
  159. async def run_connection_diagnostic(
  160. ip_address: str,
  161. *,
  162. printer: Printer | None = None,
  163. serial_number: str | None = None,
  164. access_code: str | None = None,
  165. wait_for_publish_seconds: float = 0.0,
  166. ) -> PrinterDiagnosticResult:
  167. """Run connection checks for a printer.
  168. Works for an existing saved printer (pass ``printer``) and for the
  169. pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
  170. Each check carries a stable ``id`` and a ``status`` of
  171. pass / fail / warn / skip; the frontend renders the human-readable
  172. title and fix text (localized) keyed on that id + status.
  173. """
  174. checks: list[DiagnosticCheck] = []
  175. # --- Port reachability (probed in parallel) ---
  176. camera_port, camera_protocol = _camera_port_for_printer(printer)
  177. mqtt_ok, ftps_state, camera_ok = await asyncio.gather(
  178. _check_port(ip_address, PORT_MQTT),
  179. _check_ftps_tls(ip_address, getattr(printer, "model", None) if printer else None),
  180. _check_port(ip_address, camera_port),
  181. )
  182. # MQTT is connection-critical; FTPS/camera only degrade printing/camera.
  183. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
  184. # "no_tls" gets its own message: the port is open, so the usual advice
  185. # (unblock port 990) is wrong and only a printer restart helps (#2780).
  186. checks.append(
  187. DiagnosticCheck(
  188. id="port_ftps",
  189. status="pass" if ftps_state == "ok" else "warn",
  190. params={} if ftps_state != "no_tls" else {"reason": "no_tls"},
  191. )
  192. )
  193. checks.append(
  194. DiagnosticCheck(
  195. id="port_rtsps",
  196. status="pass" if camera_ok else "warn",
  197. params={"port": camera_port, "protocol": camera_protocol},
  198. )
  199. )
  200. # --- Docker network mode ---
  201. network_mode: str | None = None
  202. if is_running_in_docker():
  203. network_mode = _detect_docker_network_mode()
  204. checks.append(
  205. DiagnosticCheck(
  206. id="network_mode",
  207. status="pass" if network_mode == "host" else "warn",
  208. params={"mode": network_mode},
  209. )
  210. )
  211. else:
  212. checks.append(DiagnosticCheck(id="network_mode", status="skip"))
  213. # --- Subnet match ---
  214. # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
  215. # so the comparison is meaningless and the network_mode check already covers it.
  216. if network_mode == "bridge":
  217. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  218. else:
  219. host_ip = _get_host_ip()
  220. same = _same_subnet(ip_address, host_ip) if host_ip else None
  221. if same is None:
  222. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  223. else:
  224. checks.append(
  225. DiagnosticCheck(
  226. id="subnet",
  227. status="pass" if same else "warn",
  228. params={"printer_ip": ip_address, "host_ip": host_ip},
  229. )
  230. )
  231. # --- External storage (printer-side "Store sent files on external storage") ---
  232. # Install step 4. The setting has two variants depending on
  233. # firmware/slicer combo: on newer firmware the toggle lives on the
  234. # printer (P2S 01.02 / BambuStudio 2.6+), on older versions it's
  235. # purely a slicer-side preference.
  236. #
  237. # For the printer-side variant, `home_flag` bit 11 is pushed on every
  238. # status report and parsed into state.store_to_sdcard (bambu_mqtt.py
  239. # line 153). That's the signal here — instant, no FTP I/O.
  240. #
  241. # For the slicer-side variant, the printer never hears about it and
  242. # this check will pass even when the user is missing step 4. That gap
  243. # is covered separately by the "no_3mf_available" archive-fallback
  244. # banner. An FTP upload-and-verify probe was tried and rejected — the
  245. # /cache directory is always writable from Bambuddy regardless of
  246. # either toggle, so the probe always passes and detects nothing.
  247. #
  248. # Skip entirely on models with no external-storage slot at all (A1
  249. # and A1 Mini). They never set home_flag bit 11, so a naive read of
  250. # `store_to_sdcard` would fall through to a false `fail` for every
  251. # A1-series user (#1703).
  252. #
  253. # Some models (P1-series) DO have a slot but no reachable control to turn
  254. # the option on: the Bambu Studio toggle only appears when the printer
  255. # publishes `support_save_remote_print_file_to_storage`, which current
  256. # P1 firmware never does, and the P1S/P1P have no screen. For those,
  257. # `store_to_sdcard` is stuck False with no way to fix it — report `skip`
  258. # (with a reason the UI explains) instead of a permanently-red `fail`
  259. # (#2524).
  260. state = printer_manager.get_status(printer.id) if printer else None
  261. model = getattr(printer, "model", None) if printer else None
  262. model_has_slot = has_external_storage(model) if printer else True
  263. store_to_sdcard = getattr(state, "store_to_sdcard", None) if state else None
  264. if not model_has_slot or state is None or not state.connected:
  265. checks.append(DiagnosticCheck(id="external_storage", status="skip"))
  266. elif store_to_sdcard is False and not has_remote_storage_toggle(model):
  267. # Slot present but no way to enable it on this firmware — don't nag
  268. # with an unresolvable fail; explain why via the reason param.
  269. #
  270. # Ahead of the empty-slot check below on purpose (#2524 over #2780):
  271. # on a P1-series the toggle cannot be switched on at all, so telling
  272. # the operator to insert a card would promise a fix that inserting a
  273. # card does not deliver.
  274. checks.append(
  275. DiagnosticCheck(
  276. id="external_storage",
  277. status="skip",
  278. params={"reason": "unsupported_model"},
  279. )
  280. )
  281. elif getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
  282. # The toggle can be on and still achieve nothing with an empty slot,
  283. # and that combination used to report a clean pass — #2780's H2C had
  284. # `store_to_sdcard` set and `sdcard` False for three solid weeks while
  285. # every one of its archives came out blank. Report the empty slot,
  286. # which is the part the operator can actually act on.
  287. checks.append(
  288. DiagnosticCheck(
  289. id="external_storage",
  290. status="fail",
  291. params={"reason": "no_media"},
  292. )
  293. )
  294. elif not last_print_storage_verdict(state).reachable:
  295. # The toggle is on, a card is in, and the printer still put the last
  296. # print on internal storage — which is what H2-series and P2S firmware
  297. # does, and no setting here changes it (#2762 tracks reading that
  298. # storage). A pass here would be a lie; a fail would be unresolvable.
  299. checks.append(
  300. DiagnosticCheck(
  301. id="external_storage",
  302. status="warn",
  303. params={"reason": REASON_INTERNAL_STORAGE},
  304. )
  305. )
  306. elif store_to_sdcard is True:
  307. checks.append(DiagnosticCheck(id="external_storage", status="pass"))
  308. elif store_to_sdcard is False:
  309. checks.append(DiagnosticCheck(id="external_storage", status="fail"))
  310. else:
  311. # State exists but the field was never populated — skip rather than
  312. # report a false fail.
  313. checks.append(DiagnosticCheck(id="external_storage", status="skip"))
  314. # --- MQTT credentials / connection ---
  315. if not mqtt_ok:
  316. # Can't reach the broker at all — the port check already reported it.
  317. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  318. elif serial_number and access_code:
  319. # Pre-add flow: actively probe with the credentials the user entered.
  320. try:
  321. result = await printer_manager.test_connection(
  322. ip_address=ip_address,
  323. serial_number=serial_number,
  324. access_code=access_code,
  325. )
  326. checks.append(
  327. DiagnosticCheck(
  328. id="mqtt_auth",
  329. status="pass" if result.get("success") else "fail",
  330. params=_auth_reason_params(result.get("reason")),
  331. )
  332. )
  333. except Exception:
  334. logger.debug("test_connection failed during diagnostic", exc_info=True)
  335. checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
  336. elif state is not None:
  337. # Existing printer: trust the live MQTT state rather than opening a
  338. # second connection (Bambu printers tolerate few concurrent sessions).
  339. # `connected == False` alone does not say *why* — the live client keeps
  340. # the last CONNACK refusal, so a rejected access code can be reported as
  341. # such instead of as a generic failure the user has to guess at (#2698).
  342. client = printer_manager.get_client(printer.id) if printer else None
  343. checks.append(
  344. DiagnosticCheck(
  345. id="mqtt_auth",
  346. status="pass" if state.connected else "fail",
  347. params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
  348. )
  349. )
  350. else:
  351. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  352. # --- LAN developer mode (only readable over a live MQTT connection) ---
  353. if state is not None and state.connected:
  354. if state.developer_mode is True:
  355. dev_status = "pass"
  356. elif state.developer_mode is False:
  357. dev_status = "fail"
  358. else:
  359. dev_status = "skip"
  360. checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
  361. else:
  362. checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
  363. # --- Printer is actually publishing on its report topic ---
  364. # The mqtt_auth check above only proves TCP + TLS + auth + SUBSCRIBE
  365. # succeed. A printer with a wrong-cased serial — or one that simply isn't
  366. # publishing for some other reason — still passes mqtt_auth because the
  367. # broker accepts the subscription regardless. The user-visible symptom in
  368. # that case is "AMS / K-profiles / custom filaments missing on the slicer
  369. # side": the VP bridge has nothing cached to mirror because no reports
  370. # arrived. #1622 surfaced this: bridge keep-alive timeouts paired with
  371. # the `Connected and subscribed, but the printer has sent zero status
  372. # reports` warning. The check below turns that warning into a structured
  373. # diagnostic result the user can act on without grepping container logs.
  374. #
  375. # If ``_report_messages_since_connect`` is already > 0, we exit
  376. # immediately — the bridge has seen reports. If it's 0 and a wait is
  377. # requested, we poll every PUBLISH_POLL_INTERVAL up to
  378. # ``wait_for_publish_seconds`` so a fresh reconnect (counter reset to 0)
  379. # isn't reported as fail before the printer's first idle push lands.
  380. publishing_params: dict[str, int | float] | None = None
  381. publishing_status = "skip"
  382. if printer is not None and state is not None and state.connected:
  383. client = printer_manager.get_client(printer.id)
  384. if client is not None:
  385. wait_budget = max(wait_for_publish_seconds, 0.0)
  386. if wait_budget > 0:
  387. # Expose the budget so the UI can render a countdown next to
  388. # the spinner — the user knows how long this check might take.
  389. publishing_params = {"max_wait_seconds": wait_budget}
  390. loop = asyncio.get_running_loop()
  391. deadline = loop.time() + wait_budget
  392. while True:
  393. if client.report_messages_since_connect > 0:
  394. publishing_status = "pass"
  395. break
  396. if loop.time() >= deadline:
  397. publishing_status = "fail"
  398. break
  399. await asyncio.sleep(_PUBLISH_POLL_INTERVAL)
  400. checks.append(
  401. DiagnosticCheck(
  402. id="printer_publishing",
  403. status=publishing_status,
  404. params=publishing_params or {},
  405. )
  406. )
  407. statuses = {c.status for c in checks}
  408. if "fail" in statuses:
  409. overall = "problems"
  410. elif "warn" in statuses:
  411. overall = "warnings"
  412. else:
  413. overall = "ok"
  414. return PrinterDiagnosticResult(
  415. printer_id=printer.id if printer else None,
  416. ip_address=ip_address,
  417. overall=overall,
  418. checks=checks,
  419. )