printer_diagnostic.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828
  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, container 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 json
  12. import logging
  13. import os
  14. import socket
  15. import ssl
  16. import subprocess
  17. import sys
  18. from pathlib import Path
  19. from backend.app.models.printer import Printer
  20. from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
  21. from backend.app.services.bambu_ftp import find_remote_file_async
  22. from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
  23. from backend.app.services.camera import get_camera_port
  24. from backend.app.services.discovery import OCI_RUNTIMES, detect_container_runtime
  25. from backend.app.services.ftp_profiles import get_ftp_profile
  26. from backend.app.services.network_utils import find_local_ipv4_network
  27. from backend.app.services.print_storage import (
  28. REASON_INTERNAL_STORAGE,
  29. StorageVerdict,
  30. ftp_probe_paths,
  31. last_print_storage_verdict,
  32. )
  33. from backend.app.services.printer_manager import printer_manager
  34. from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
  35. logger = logging.getLogger(__name__)
  36. # Bambu LAN-mode ports.
  37. PORT_MQTT = 8883 # MQTT over TLS — control + status. Connection-critical.
  38. PORT_FTPS = 990 # FTPS — file upload; required to send prints.
  39. PORT_RTSPS = 322 # RTSPS — camera stream; optional.
  40. PORT_CHAMBER_IMAGE = 6000 # Chamber image protocol — A1/P1 camera stream; optional.
  41. _PORT_PROBE_TIMEOUT = 3.0
  42. # Cap for the storage probe (#2856). One connection and a handful of directory
  43. # listings, so a healthy printer answers in well under a second. Kept short on
  44. # purpose: this check sits inside the support bundle's 15s-per-printer budget,
  45. # and in the interactive run it is spinner time the user is watching.
  46. _STORAGE_PROBE_TIMEOUT = 6.0
  47. # Default seconds the `printer_publishing` check will wait for the first
  48. # report-topic message before declaring fail. Bambu printers in idle publish
  49. # push_status every few seconds; 10s catches healthy bridges with margin while
  50. # staying short enough that the spinner-with-countdown UX stays acceptable.
  51. # The check exits the moment a message arrives, so the typical wall-clock is
  52. # 1–2s, not the full 10. Passed as ``wait_for_publish_seconds`` per call so
  53. # the support-package code path can skip the wait entirely (defaults to 0).
  54. PUBLISH_WAIT_DEFAULT = 10.0
  55. _PUBLISH_POLL_INTERVAL = 0.5
  56. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  57. """Test TCP connectivity to ip:port. Returns True if reachable."""
  58. try:
  59. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  60. writer.close()
  61. try:
  62. await writer.wait_closed()
  63. except Exception:
  64. pass
  65. return True
  66. except Exception:
  67. return False
  68. # Public alias. The connection watchdog probes the MQTT port before rebuilding a
  69. # client, so it can tell "the printer is switched off" (leave it alone, paho will
  70. # keep retrying) from "the printer is answering but our session is dead" (#2732).
  71. check_port = _check_port
  72. async def _check_ftps_tls(ip: str, model: str | None, timeout: float = _PORT_PROBE_TIMEOUT) -> str:
  73. """Probe port 990 the way the FTP client does, and say how far it got.
  74. Returns ``"ok"``, ``"closed"`` (nothing accepted the TCP connection) or
  75. ``"no_tls"`` (the port accepted the connection but the TLS handshake did
  76. not complete).
  77. A plain TCP probe cannot tell the last two apart, which is exactly how
  78. #2780 hid: the reporter's diagnostic reported port 990 as reachable and
  79. green while every real transfer died in the handshake with
  80. ``WRONG_VERSION_NUMBER``, so archives quietly arrived empty with nothing
  81. on screen to explain it.
  82. The context mirrors :class:`~backend.app.services.bambu_ftp.ImplicitFTP_TLS`
  83. -- including the model's TLS cap -- so a pass here means the FTP client
  84. would also get through. Handshake only; no login is attempted, so this
  85. stays valid for the pre-save Add-Printer flow where no access code exists
  86. yet.
  87. """
  88. context = ssl.create_default_context()
  89. context.check_hostname = False
  90. context.verify_mode = ssl.CERT_NONE
  91. context.minimum_version = ssl.TLSVersion.TLSv1_2
  92. if get_ftp_profile(model).cap_tls_v1_2:
  93. context.maximum_version = ssl.TLSVersion.TLSv1_2
  94. writer = None
  95. try:
  96. _reader, writer = await asyncio.wait_for(
  97. asyncio.open_connection(ip, PORT_FTPS, ssl=context),
  98. timeout=timeout,
  99. )
  100. return "ok"
  101. except ssl.SSLError:
  102. # The socket was accepted and then failed to negotiate TLS. Reaching
  103. # here at all proves something is listening, so this is never "port
  104. # blocked" -- it is the printer's file service in a state no retry
  105. # gets past.
  106. return "no_tls"
  107. except Exception:
  108. return "closed"
  109. finally:
  110. if writer is not None:
  111. writer.close()
  112. try:
  113. await writer.wait_closed()
  114. except Exception:
  115. pass
  116. async def _last_print_file_is_reachable(
  117. printer: Printer | None,
  118. verdict: StorageVerdict,
  119. *,
  120. ftps_ok: bool,
  121. ) -> bool:
  122. """Did the last print's file turn up on external storage after all? (#2856)
  123. ``verdict`` is read off the dispatch URL, which says where the printer
  124. *put* the file, not whether port 990 can serve it: an H2D with a card in
  125. the slot reports ``brtc://emmc/<name>`` and then hands the same file over
  126. from ``/cache`` without complaint. Warning that the file is out of reach
  127. while that user's archives are quietly complete would send them chasing a
  128. setting that is already right.
  129. So check before saying it -- one connection, one listing per candidate
  130. directory, no transfer, and only for the check that is about to warn.
  131. "Could not check" returns False and leaves the warning standing, which is
  132. the safe direction: it is a warn, not a fail.
  133. """
  134. if not ftps_ok or not verdict.probe_filename or printer is None:
  135. return False
  136. # Attribute reads inside the guard too: this also runs from the support
  137. # bundle, where the row may outlive its session, and a detached-instance
  138. # error there is "could not check", not a broken diagnostic.
  139. ip_address = None
  140. try:
  141. ip_address = getattr(printer, "ip_address", None)
  142. access_code = getattr(printer, "access_code", None)
  143. if not ip_address or not access_code:
  144. return False
  145. found = await find_remote_file_async(
  146. ip_address,
  147. access_code,
  148. ftp_probe_paths(verdict.probe_filename),
  149. timeout=_STORAGE_PROBE_TIMEOUT,
  150. socket_timeout=_STORAGE_PROBE_TIMEOUT,
  151. printer_model=getattr(printer, "model", None),
  152. )
  153. except Exception as e:
  154. logger.debug("Could not probe %s for %s: %s", ip_address, verdict.probe_filename, e)
  155. return False
  156. if found:
  157. logger.debug("Last print file is on external storage at %s despite %s", found, verdict.reason)
  158. return found is not None
  159. def _auth_reason_params(reason: str | None) -> dict:
  160. """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
  161. The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
  162. is present and falls back to the plain per-status text otherwise, so an
  163. unknown or absent slug degrades to today's generic wording rather than a
  164. missing string. Only `auth_rejected` currently carries its own message:
  165. that is the one case where the printer positively told us the credentials
  166. were wrong, as opposed to us merely observing that we are not connected.
  167. """
  168. if reason == CONNECT_ERROR_AUTH_REJECTED:
  169. return {"reason": CONNECT_ERROR_AUTH_REJECTED}
  170. return {}
  171. def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
  172. """Return the model-specific camera diagnostic port and display protocol."""
  173. if not printer:
  174. return PORT_RTSPS, "RTSPS"
  175. model = getattr(printer, "model", None)
  176. if not model:
  177. return PORT_RTSPS, "RTSPS"
  178. camera_port = get_camera_port(model)
  179. if camera_port == PORT_CHAMBER_IMAGE:
  180. return camera_port, "Chamber Image"
  181. return camera_port, "RTSPS"
  182. # Interfaces a container engine creates on the *host*. Seeing one of them
  183. # means we are in the host's network namespace.
  184. _HOST_INFRA_PREFIXES = ("docker", "br-", "veth", "virbr", "podman", "cni-", "cni_")
  185. def _has_native_interface() -> bool:
  186. """True if some interface here was created in this network namespace.
  187. A NAT-networked container is handed one end of a veth pair per attached
  188. network, and a veth's ``iflink`` points at its peer's index in the *other*
  189. namespace, so it never equals its own ``ifindex``. An interface where the
  190. two agree was made here — a physical NIC, a bridge, a VLAN — which a
  191. container with its own namespace does not get.
  192. tun/tap devices are skipped: a container can legitimately run its own
  193. WireGuard or Tailscale client, and that tun would otherwise read as
  194. evidence of a namespace it is not evidence of.
  195. """
  196. try:
  197. entries = [(idx, name) for idx, name in socket.if_nameindex() if name != "lo"]
  198. except Exception:
  199. return False
  200. for index, name in entries:
  201. # Never user input: the kernel's own interface table, and never a path.
  202. iface = Path("/sys/class/net") / name # SEC-PATH-OK: name from socket.if_nameindex()
  203. if (iface / "tun_flags").exists():
  204. continue
  205. try:
  206. ifindex = (iface / "ifindex").read_text().strip()
  207. iflink = (iface / "iflink").read_text().strip()
  208. except (OSError, ValueError):
  209. continue
  210. # sysfs is tagged by network namespace, but a container given a bind
  211. # mount of the host's /sys sees the host's interfaces under names that
  212. # may collide with its own. Reading a different interface's numbers
  213. # would be reading another namespace's answer, so require that the
  214. # entry found here is the one the kernel just named.
  215. if ifindex != str(index):
  216. continue
  217. if ifindex == iflink:
  218. return True
  219. return False
  220. # Link kinds that put a container straight onto the LAN with an address of
  221. # its own: the parent NIC's network, not a NAT behind the host.
  222. _LAN_LINK_KINDS = ("macvlan", "ipvlan")
  223. # The ``ip`` call reads the kernel's own table; a guard, not a budget.
  224. _IP_TIMEOUT = 2.0
  225. def _lan_link_kinds() -> dict[str, str] | None:
  226. """The macvlan/ipvlan interfaces here: name -> "macvlan", "ipvlan" or
  227. "ipvlan_l3" (L3 or L3S mode). None when the link kinds can't be read.
  228. A macvlan or ipvlan interface is created in the host's namespace and
  229. moved into the container's, so like a veth its ``iflink`` names an
  230. interface elsewhere and ``_has_native_interface`` passes it by: such a
  231. container read as bridge networking (#3417). Only the kernel's link kind
  232. tells them apart, and only netlink carries it (``ip -d``): neither driver
  233. gives sysfs a device type to read instead.
  234. """
  235. try:
  236. result = subprocess.run(["ip", "-d", "-j", "link", "show"], capture_output=True, text=True, timeout=_IP_TIMEOUT)
  237. if result.returncode != 0:
  238. return None
  239. found: dict[str, str] = {}
  240. for link in json.loads(result.stdout or "[]"):
  241. info = link.get("linkinfo") or {}
  242. kind = info.get("info_kind")
  243. if kind not in _LAN_LINK_KINDS or not link.get("ifname"):
  244. continue
  245. mode = (info.get("info_data") or {}).get("mode") if kind == "ipvlan" else None
  246. # iproute2 prints l2/l3/l3s; L3 routes, so no broadcast or
  247. # multicast crosses it. Any other mode is not known to.
  248. found[link["ifname"]] = "ipvlan_l3" if mode in ("l3", "l3s") else kind
  249. return found
  250. except Exception:
  251. logger.debug("ip -d link probe failed", exc_info=True)
  252. return None
  253. def _lan_link_mode() -> str | None:
  254. """How a macvlan or ipvlan interface here connects: ``macvlan``,
  255. ``ipvlan`` (L2, or a mode not known to route), ``ipvlan_l3``; ``none``
  256. when there is no such interface, None when it can't be told."""
  257. kinds = _lan_link_kinds()
  258. if kinds is None:
  259. return None
  260. values = set(kinds.values())
  261. # A container on a bridge network and a macvlan one reaches the LAN
  262. # through the macvlan; the most capable link answers.
  263. for mode in ("macvlan", "ipvlan", "ipvlan_l3"):
  264. if mode in values:
  265. return mode
  266. return "none"
  267. def _detect_container_network_mode(runtime: str | None) -> str | None:
  268. """Return "host", "bridge", "macvlan", "ipvlan", "ipvlan_l3", or None
  269. when it genuinely cannot be told. Reads the kernel's link table through
  270. ``ip``: call it off the event loop.
  271. The first rule is the original Docker one and is kept exactly: a Docker
  272. *host* always has a docker0, so a container that can see it shares the
  273. host's namespace. It says nothing about Podman, which on a host running
  274. no bridge containers creates no such interface at all — which is how a
  275. host-networked Podman container came to be told it was on bridge
  276. networking (#3092).
  277. The second rule is the general form of the same idea and is what answers
  278. for Podman. The third is the fallback the first rule always implied: an
  279. OCI container that can see neither is isolated, which is what bridge
  280. networking means -- unless its interface is a macvlan or ipvlan, which
  281. puts it on the LAN with an address of its own (#3417).
  282. """
  283. try:
  284. for _idx, name in socket.if_nameindex():
  285. if name.startswith(_HOST_INFRA_PREFIXES):
  286. return "host"
  287. except Exception:
  288. pass
  289. if _has_native_interface():
  290. return "host"
  291. if runtime not in OCI_RUNTIMES:
  292. return None
  293. lan = _lan_link_mode()
  294. # None without the link kinds: a veth and a macvlan look alike then.
  295. return "bridge" if lan == "none" else lan
  296. def _host_source_ip(destination_ip: str) -> str | None:
  297. """The local IPv4 address Bambuddy would send from toward ``destination_ip``.
  298. Asking about the printer's own address rather than a fixed far-away one
  299. matters on any host with more than one NIC: the source for a route to the
  300. internet is simply not the source for a route to the printer, and
  301. comparing the printer against the wrong interface is a warning about
  302. nothing (#3092).
  303. Literals only. ``connect()`` on a name would resolve it, and this runs on
  304. the event loop; ``_same_subnet`` rejects names anyway, so nothing is lost.
  305. """
  306. try:
  307. if ipaddress.ip_address(destination_ip).version != 4:
  308. return None
  309. except ValueError:
  310. return None
  311. try:
  312. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  313. try:
  314. # No packets are sent; this just picks the routing-table source IP.
  315. s.connect((destination_ip, 1))
  316. return s.getsockname()[0]
  317. finally:
  318. s.close()
  319. except Exception:
  320. # Fail soft: this is a diagnostic, and an unroutable address or an
  321. # exhausted fd table must leave the check skipped, not 500 the page.
  322. return None
  323. def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
  324. """Is ``printer_ip`` inside the network configured on Bambuddy's ``host_ip``?
  325. None means undeterminable — a name instead of an IPv4 literal, or no
  326. local interface claiming ``host_ip``.
  327. An address does not carry its prefix, and this used to supply ``/24`` for
  328. both sides. That is the most common LAN and not the only one: on the
  329. reporter's ``192.168.96.0/22`` it declared a printer four hundred
  330. addresses away to be on a different network and told him to go configure
  331. routing between two halves of one subnet (#3092). The prefix is read off
  332. the interface that owns the source address instead.
  333. """
  334. try:
  335. printer_addr = ipaddress.ip_address(printer_ip)
  336. host_addr = ipaddress.ip_address(host_ip)
  337. except ValueError:
  338. return None
  339. if printer_addr.version != 4 or host_addr.version != 4:
  340. return None
  341. network = find_local_ipv4_network(str(host_addr))
  342. if network is None:
  343. return None
  344. return printer_addr in network
  345. # macOS attributes Local Network permission (TCC) to a process's code
  346. # signature, and judges a launchd-spawned process on its own instead of
  347. # letting it inherit the grant of the Terminal that started it. Homebrew's
  348. # Python is unsigned on Intel, so there is no identity for a grant to attach
  349. # to: every connection to a LAN address is dropped, with no error the
  350. # application can log and no permission prompt. All three printer ports read
  351. # as unreachable while the subnet check passes (#3114).
  352. _CODESIGN = "/usr/bin/codesign"
  353. # Reading a local file's signature takes milliseconds, so this is a guard
  354. # rather than a budget -- and it is deliberately short. The support bundle
  355. # gives each printer 15s total (_PER_DIAGNOSTIC_TIMEOUT_SECONDS) and drops
  356. # the whole connection diagnostic on overrun, so a codesign that hangs (the
  357. # stub that offers to install the command line tools is the plausible way)
  358. # must not be able to cost the bundle the rest of its checks.
  359. _CODESIGN_TIMEOUT = 2.0
  360. def _base_interpreter_path() -> str:
  361. """The interpreter macOS judges, as both the probe and the message see it.
  362. ``sys._base_executable`` rather than ``sys.executable``: inside a venv the
  363. latter is a symlink in the venv's own bin directory, and what macOS judges
  364. is the real interpreter it resolves to. Resolved once, here, so the path
  365. reported to the user is the same one whose signature was read.
  366. """
  367. return os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
  368. def _interpreter_is_signed() -> bool | None:
  369. """Does the interpreter Bambuddy runs under carry a code signature?
  370. None when it cannot be told: no usable ``codesign`` because the Xcode
  371. command line tools are absent, or the probe failed some other way. That
  372. is deliberately not folded into False. The advice for "no identity" names
  373. a repair that rewrites a file inside the user's Python installation, and
  374. offering that on a guess is worse than giving the generic answer.
  375. On an Apple Silicon Homebrew install the interpreter resolves to the
  376. framework's ``bin/pythonX.Y`` (measured, inside and outside a venv alike)
  377. -- not the ``Python.app`` stub, which is a separate binary in the same
  378. framework. The reporter's TCC log names the same ``bin/pythonX.Y`` on
  379. Intel.
  380. """
  381. executable = _base_interpreter_path()
  382. if not executable:
  383. return None
  384. try:
  385. result = subprocess.run(
  386. [_CODESIGN, "-d", executable],
  387. capture_output=True,
  388. text=True,
  389. timeout=_CODESIGN_TIMEOUT,
  390. )
  391. except Exception:
  392. # Fail soft, as everywhere else in this module: a diagnostic that
  393. # raises is worse than one that declines to answer.
  394. logger.debug("codesign probe failed", exc_info=True)
  395. return None
  396. if result.returncode == 0:
  397. return True
  398. # codesign writes this to stderr and exits non-zero. It is the one
  399. # outcome that separates "no identity at all" from "the probe never ran".
  400. if "not signed at all" in result.stderr:
  401. return False
  402. return None
  403. async def run_connection_diagnostic(
  404. ip_address: str,
  405. *,
  406. printer: Printer | None = None,
  407. serial_number: str | None = None,
  408. access_code: str | None = None,
  409. wait_for_publish_seconds: float = 0.0,
  410. progress: dict | None = None,
  411. ) -> PrinterDiagnosticResult:
  412. """Run connection checks for a printer.
  413. Works for an existing saved printer (pass ``printer``) and for the
  414. pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
  415. Each check carries a stable ``id`` and a ``status`` of
  416. pass / fail / warn / skip; the frontend renders the human-readable
  417. title and fix text (localized) keyed on that id + status.
  418. ``progress``, when given, is kept current while the checks run: its
  419. ``checks`` key is the list of finished checks and ``stage`` names the step
  420. in flight. The support bundle cancels a run that overruns its budget, and
  421. this is how it can still say which step hung and keep what finished --
  422. a bare "timed out" for every printer is all #3164's bundle could show.
  423. """
  424. checks: list[DiagnosticCheck] = []
  425. if progress is None:
  426. progress = {}
  427. progress["checks"] = checks
  428. # --- Port reachability (probed in parallel) ---
  429. progress["stage"] = "ports"
  430. camera_port, camera_protocol = _camera_port_for_printer(printer)
  431. mqtt_ok, ftps_state, camera_ok = await asyncio.gather(
  432. _check_port(ip_address, PORT_MQTT),
  433. _check_ftps_tls(ip_address, getattr(printer, "model", None) if printer else None),
  434. _check_port(ip_address, camera_port),
  435. )
  436. # MQTT is connection-critical; FTPS/camera only degrade printing/camera.
  437. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
  438. # "no_tls" gets its own message: the port is open, so the usual advice
  439. # (unblock port 990) is wrong and only a printer restart helps (#2780).
  440. checks.append(
  441. DiagnosticCheck(
  442. id="port_ftps",
  443. status="pass" if ftps_state == "ok" else "warn",
  444. params={} if ftps_state != "no_tls" else {"reason": "no_tls"},
  445. )
  446. )
  447. checks.append(
  448. DiagnosticCheck(
  449. id="port_rtsps",
  450. status="pass" if camera_ok else "warn",
  451. params={"port": camera_port, "protocol": camera_protocol},
  452. )
  453. )
  454. # --- macOS Local Network permission ---
  455. progress["stage"] = "macos_local_network"
  456. # Appended on macOS only. Everywhere else there is nothing to say, and a
  457. # permanently dimmed "skipped" row would be noise for the users who make
  458. # up nearly all of them.
  459. #
  460. # Both outcomes are reported as warn rather than fail, and only when the
  461. # control port is already unreachable -- so this can never be the check
  462. # that turns an otherwise healthy result red. A printer that is simply
  463. # switched off produces the same all-ports-dead pattern, which is why the
  464. # signature probe, not the pattern, is what earns the specific advice.
  465. if sys.platform == "darwin":
  466. if mqtt_ok:
  467. # The control port answered, so LAN access demonstrably works.
  468. checks.append(DiagnosticCheck(id="macos_local_network", status="pass"))
  469. else:
  470. signed = await asyncio.to_thread(_interpreter_is_signed)
  471. if signed is False:
  472. checks.append(
  473. DiagnosticCheck(
  474. id="macos_local_network",
  475. status="warn",
  476. params={"reason": "unsigned", "executable": _base_interpreter_path()},
  477. )
  478. )
  479. else:
  480. # Signed, or undeterminable. An ad-hoc signature -- which is
  481. # what every arm64 binary carries, because the linker adds one
  482. # -- identifies itself by a hash of the binary, so a Python
  483. # upgrade presents macOS with a new application and leaves the
  484. # old grant behind. That is repairable in System Settings,
  485. # unlike the unsigned case, so point there instead.
  486. checks.append(DiagnosticCheck(id="macos_local_network", status="warn", params={"reason": "permission"}))
  487. # --- Container network mode ---
  488. progress["stage"] = "network_mode"
  489. # Not Docker-only: Podman runs Bambuddy in exactly the same two shapes and
  490. # its users were told "Not running in Docker", which reads as "you are on
  491. # bare metal" and sent them looking for the problem somewhere else (#3092).
  492. runtime = detect_container_runtime()
  493. network_mode: str | None = None
  494. if runtime is None:
  495. checks.append(DiagnosticCheck(id="network_mode", status="skip"))
  496. elif runtime not in OCI_RUNTIMES:
  497. # An LXC/LXD system container is bridged onto the LAN like a small VM.
  498. # There is no network mode to recommend, so don't imply there is one.
  499. checks.append(
  500. DiagnosticCheck(id="network_mode", status="skip", params={"reason": "system_container", "runtime": runtime})
  501. )
  502. else:
  503. # Off the loop: the link kinds come from `ip -d link show`.
  504. network_mode = await asyncio.to_thread(_detect_container_network_mode, runtime)
  505. if network_mode is None:
  506. checks.append(
  507. DiagnosticCheck(id="network_mode", status="skip", params={"reason": "unknown", "runtime": runtime})
  508. )
  509. else:
  510. # macvlan and ipvlan L2 sit on the LAN with their own address, so
  511. # discovery and the Virtual Printer work there. ipvlan L3 routes
  512. # instead: no broadcast or multicast, so SSDP discovery can't.
  513. params: dict = {"mode": network_mode, "runtime": runtime}
  514. if network_mode in ("macvlan", "ipvlan", "ipvlan_l3"):
  515. params["reason"] = network_mode
  516. checks.append(
  517. DiagnosticCheck(
  518. id="network_mode",
  519. status="pass" if network_mode in ("host", "macvlan", "ipvlan") else "warn",
  520. params=params,
  521. )
  522. )
  523. # --- Subnet match ---
  524. progress["stage"] = "subnet"
  525. # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
  526. # so the comparison is meaningless and the network_mode check already covers it.
  527. if network_mode == "bridge":
  528. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  529. else:
  530. host_ip = _host_source_ip(ip_address)
  531. # Off the loop: resolving the prefix shells out to `ip -j addr show`.
  532. same = await asyncio.to_thread(_same_subnet, ip_address, host_ip) if host_ip else None
  533. if same is None:
  534. checks.append(DiagnosticCheck(id="subnet", status="skip"))
  535. else:
  536. checks.append(
  537. DiagnosticCheck(
  538. id="subnet",
  539. status="pass" if same else "warn",
  540. params={"printer_ip": ip_address, "host_ip": host_ip},
  541. )
  542. )
  543. # --- External storage (printer-side "Store sent files on external storage") ---
  544. progress["stage"] = "external_storage"
  545. # Install step 4. The setting has two variants depending on
  546. # firmware/slicer combo: on newer firmware the toggle lives on the
  547. # printer (P2S 01.02 / BambuStudio 2.6+), on older versions it's
  548. # purely a slicer-side preference.
  549. #
  550. # For the printer-side variant, `home_flag` bit 11 is pushed on every
  551. # status report and parsed into state.store_to_sdcard (bambu_mqtt.py
  552. # line 153). That's the signal here — instant, no FTP I/O.
  553. #
  554. # For the slicer-side variant, the printer never hears about it and
  555. # this check will pass even when the user is missing step 4. That gap
  556. # is covered separately by the "no_3mf_available" archive-fallback
  557. # banner. An FTP upload-and-verify probe was tried and rejected — the
  558. # /cache directory is always writable from Bambuddy regardless of
  559. # either toggle, so the probe always passes and detects nothing.
  560. #
  561. # Skip entirely on models with no external-storage slot at all (A1
  562. # and A1 Mini). They never set home_flag bit 11, so a naive read of
  563. # `store_to_sdcard` would fall through to a false `fail` for every
  564. # A1-series user (#1703).
  565. #
  566. # Some models (P1-series) DO have a slot but no reachable control to turn
  567. # the option on: the Bambu Studio toggle only appears when the printer
  568. # publishes `support_save_remote_print_file_to_storage`, which current
  569. # P1 firmware never does, and the P1S/P1P have no screen. For those,
  570. # `store_to_sdcard` is stuck False with no way to fix it — report `skip`
  571. # (with a reason the UI explains) instead of a permanently-red `fail`
  572. # (#2524).
  573. state = printer_manager.get_status(printer.id) if printer else None
  574. model = getattr(printer, "model", None) if printer else None
  575. model_has_slot = has_external_storage(model) if printer else True
  576. store_to_sdcard = getattr(state, "store_to_sdcard", None) if state else None
  577. if not model_has_slot or state is None or not state.connected:
  578. checks.append(DiagnosticCheck(id="external_storage", status="skip"))
  579. elif store_to_sdcard is False and not has_remote_storage_toggle(model):
  580. # Slot present but no way to enable it on this firmware — don't nag
  581. # with an unresolvable fail; explain why via the reason param.
  582. #
  583. # Ahead of the empty-slot check below on purpose (#2524 over #2780):
  584. # on a P1-series the toggle cannot be switched on at all, so telling
  585. # the operator to insert a card would promise a fix that inserting a
  586. # card does not deliver.
  587. checks.append(
  588. DiagnosticCheck(
  589. id="external_storage",
  590. status="skip",
  591. params={"reason": "unsupported_model"},
  592. )
  593. )
  594. elif getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
  595. # The toggle can be on and still achieve nothing with an empty slot,
  596. # and that combination used to report a clean pass — #2780's H2C had
  597. # `store_to_sdcard` set and `sdcard` False for three solid weeks while
  598. # every one of its archives came out blank. Report the empty slot,
  599. # which is the part the operator can actually act on.
  600. checks.append(
  601. DiagnosticCheck(
  602. id="external_storage",
  603. status="fail",
  604. params={"reason": "no_media"},
  605. )
  606. )
  607. elif not (last_verdict := last_print_storage_verdict(state)).reachable and not await _last_print_file_is_reachable(
  608. printer, last_verdict, ftps_ok=ftps_state == "ok"
  609. ):
  610. # The toggle is on, a card is in, the printer said the last print's file
  611. # is on internal storage — and a probe confirmed it really is out of
  612. # reach. That is what H2-series, P2S and X2D firmware does, and no setting
  613. # here changes it (#2762 tracks reading that storage). A pass here would
  614. # be a lie; a fail would be unresolvable.
  615. #
  616. # The verdict's own reason, not a fixed one: a print started from the
  617. # printer's screen reaches this branch too, and it never involved a
  618. # slicer, so the advice attached to REASON_INTERNAL_STORAGE would name a
  619. # dialog its operator never opened (#1820).
  620. checks.append(
  621. DiagnosticCheck(
  622. id="external_storage",
  623. status="warn",
  624. params={"reason": last_verdict.reason or REASON_INTERNAL_STORAGE},
  625. )
  626. )
  627. elif not last_verdict.reachable:
  628. # Reached only when the probe above found the file: the printer named
  629. # internal storage and served it over FTPS anyway. What this check is
  630. # for is whether Bambuddy can read the print file, and it demonstrably
  631. # can — so pass, whatever the toggle happens to say (#2856).
  632. checks.append(DiagnosticCheck(id="external_storage", status="pass"))
  633. elif store_to_sdcard is True:
  634. checks.append(DiagnosticCheck(id="external_storage", status="pass"))
  635. elif store_to_sdcard is False:
  636. checks.append(DiagnosticCheck(id="external_storage", status="fail"))
  637. else:
  638. # State exists but the field was never populated — skip rather than
  639. # report a false fail.
  640. checks.append(DiagnosticCheck(id="external_storage", status="skip"))
  641. # --- MQTT credentials / connection ---
  642. progress["stage"] = "mqtt_auth"
  643. if not mqtt_ok:
  644. # Can't reach the broker at all — the port check already reported it.
  645. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  646. elif serial_number and access_code:
  647. # Pre-add flow: actively probe with the credentials the user entered.
  648. try:
  649. result = await printer_manager.test_connection(
  650. ip_address=ip_address,
  651. serial_number=serial_number,
  652. access_code=access_code,
  653. )
  654. checks.append(
  655. DiagnosticCheck(
  656. id="mqtt_auth",
  657. status="pass" if result.get("success") else "fail",
  658. params=_auth_reason_params(result.get("reason")),
  659. )
  660. )
  661. except Exception:
  662. logger.debug("test_connection failed during diagnostic", exc_info=True)
  663. checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
  664. elif state is not None:
  665. # Existing printer: trust the live MQTT state rather than opening a
  666. # second connection (Bambu printers tolerate few concurrent sessions).
  667. # `connected == False` alone does not say *why* — the live client keeps
  668. # the last CONNACK refusal, so a rejected access code can be reported as
  669. # such instead of as a generic failure the user has to guess at (#2698).
  670. client = printer_manager.get_client(printer.id) if printer else None
  671. checks.append(
  672. DiagnosticCheck(
  673. id="mqtt_auth",
  674. status="pass" if state.connected else "fail",
  675. params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
  676. )
  677. )
  678. else:
  679. checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
  680. # --- LAN developer mode (only readable over a live MQTT connection) ---
  681. progress["stage"] = "developer_mode"
  682. if state is not None and state.connected:
  683. if state.developer_mode is True:
  684. dev_status = "pass"
  685. elif state.developer_mode is False:
  686. dev_status = "fail"
  687. else:
  688. dev_status = "skip"
  689. checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
  690. else:
  691. checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
  692. # --- Printer is actually publishing on its report topic ---
  693. progress["stage"] = "printer_publishing"
  694. # The mqtt_auth check above only proves TCP + TLS + auth + SUBSCRIBE
  695. # succeed. A printer with a wrong-cased serial — or one that simply isn't
  696. # publishing for some other reason — still passes mqtt_auth because the
  697. # broker accepts the subscription regardless. The user-visible symptom in
  698. # that case is "AMS / K-profiles / custom filaments missing on the slicer
  699. # side": the VP bridge has nothing cached to mirror because no reports
  700. # arrived. #1622 surfaced this: bridge keep-alive timeouts paired with
  701. # the `Connected and subscribed, but the printer has sent zero status
  702. # reports` warning. The check below turns that warning into a structured
  703. # diagnostic result the user can act on without grepping container logs.
  704. #
  705. # If ``_report_messages_since_connect`` is already > 0, we exit
  706. # immediately — the bridge has seen reports. If it's 0 and a wait is
  707. # requested, we poll every PUBLISH_POLL_INTERVAL up to
  708. # ``wait_for_publish_seconds`` so a fresh reconnect (counter reset to 0)
  709. # isn't reported as fail before the printer's first idle push lands.
  710. publishing_params: dict[str, int | float] | None = None
  711. publishing_status = "skip"
  712. if printer is not None and state is not None and state.connected:
  713. client = printer_manager.get_client(printer.id)
  714. if client is not None:
  715. wait_budget = max(wait_for_publish_seconds, 0.0)
  716. if wait_budget > 0:
  717. # Expose the budget so the UI can render a countdown next to
  718. # the spinner — the user knows how long this check might take.
  719. publishing_params = {"max_wait_seconds": wait_budget}
  720. loop = asyncio.get_running_loop()
  721. deadline = loop.time() + wait_budget
  722. while True:
  723. if client.report_messages_since_connect > 0:
  724. publishing_status = "pass"
  725. break
  726. if loop.time() >= deadline:
  727. publishing_status = "fail"
  728. break
  729. await asyncio.sleep(_PUBLISH_POLL_INTERVAL)
  730. checks.append(
  731. DiagnosticCheck(
  732. id="printer_publishing",
  733. status=publishing_status,
  734. params=publishing_params or {},
  735. )
  736. )
  737. statuses = {c.status for c in checks}
  738. if "fail" in statuses:
  739. overall = "problems"
  740. elif "warn" in statuses:
  741. overall = "warnings"
  742. else:
  743. overall = "ok"
  744. return PrinterDiagnosticResult(
  745. printer_id=printer.id if printer else None,
  746. ip_address=ip_address,
  747. overall=overall,
  748. checks=checks,
  749. )