| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- """Connection diagnostic for Bambu printers.
- Runs the checks a maintainer performs by hand when triaging a
- "printer won't connect / won't print" report — port reachability, LAN
- developer mode, Docker network mode, subnet match, and MQTT credentials —
- so users can self-diagnose setup problems instead of opening an issue.
- See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
- user-side setup errors clustered on exactly these causes.
- """
- import asyncio
- import ipaddress
- import logging
- import socket
- from backend.app.models.printer import Printer
- from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
- from backend.app.services.discovery import is_running_in_docker
- from backend.app.services.printer_manager import printer_manager
- logger = logging.getLogger(__name__)
- # Bambu LAN-mode ports.
- PORT_MQTT = 8883 # MQTT over TLS — control + status. Connection-critical.
- PORT_FTPS = 990 # FTPS — file upload; required to send prints.
- PORT_RTSPS = 322 # RTSPS — camera stream; optional.
- _PORT_PROBE_TIMEOUT = 3.0
- async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
- """Test TCP connectivity to ip:port. Returns True if reachable."""
- try:
- _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
- writer.close()
- try:
- await writer.wait_closed()
- except Exception:
- pass
- return True
- except Exception:
- return False
- def _detect_docker_network_mode() -> str:
- """Detect Docker network mode.
- In host mode the container shares the host network namespace, so Docker
- infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
- mode the container only sees its own eth0.
- """
- try:
- for _idx, name in socket.if_nameindex():
- if name.startswith(("docker", "br-", "veth", "virbr")):
- return "host"
- except Exception:
- pass
- return "bridge"
- def _get_host_ip() -> str | None:
- """Best-effort IPv4 address the Bambuddy host routes from."""
- try:
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- try:
- # No packets are sent; this just picks the routing-table source IP.
- s.connect(("10.255.255.255", 1))
- return s.getsockname()[0]
- finally:
- s.close()
- except Exception:
- return None
- def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
- """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
- try:
- addr_a = ipaddress.ip_address(ip_a)
- addr_b = ipaddress.ip_address(ip_b)
- except ValueError:
- return None
- if addr_a.version != 4 or addr_b.version != 4:
- return None
- net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
- net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
- return net_a == net_b
- async def run_connection_diagnostic(
- ip_address: str,
- *,
- printer: Printer | None = None,
- serial_number: str | None = None,
- access_code: str | None = None,
- ) -> PrinterDiagnosticResult:
- """Run connection checks for a printer.
- Works for an existing saved printer (pass ``printer``) and for the
- pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
- Each check carries a stable ``id`` and a ``status`` of
- pass / fail / warn / skip; the frontend renders the human-readable
- title and fix text (localized) keyed on that id + status.
- """
- checks: list[DiagnosticCheck] = []
- # --- Port reachability (probed in parallel) ---
- mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
- _check_port(ip_address, PORT_MQTT),
- _check_port(ip_address, PORT_FTPS),
- _check_port(ip_address, PORT_RTSPS),
- )
- # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
- checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
- checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
- checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
- # --- Docker network mode ---
- network_mode: str | None = None
- if is_running_in_docker():
- network_mode = _detect_docker_network_mode()
- checks.append(
- DiagnosticCheck(
- id="network_mode",
- status="pass" if network_mode == "host" else "warn",
- params={"mode": network_mode},
- )
- )
- else:
- checks.append(DiagnosticCheck(id="network_mode", status="skip"))
- # --- Subnet match ---
- # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
- # so the comparison is meaningless and the network_mode check already covers it.
- if network_mode == "bridge":
- checks.append(DiagnosticCheck(id="subnet", status="skip"))
- else:
- host_ip = _get_host_ip()
- same = _same_subnet(ip_address, host_ip) if host_ip else None
- if same is None:
- checks.append(DiagnosticCheck(id="subnet", status="skip"))
- else:
- checks.append(
- DiagnosticCheck(
- id="subnet",
- status="pass" if same else "warn",
- params={"printer_ip": ip_address, "host_ip": host_ip},
- )
- )
- # --- MQTT credentials / connection ---
- state = printer_manager.get_status(printer.id) if printer else None
- if not mqtt_ok:
- # Can't reach the broker at all — the port check already reported it.
- checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
- elif serial_number and access_code:
- # Pre-add flow: actively probe with the credentials the user entered.
- try:
- result = await printer_manager.test_connection(
- ip_address=ip_address,
- serial_number=serial_number,
- access_code=access_code,
- )
- checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
- except Exception:
- logger.debug("test_connection failed during diagnostic", exc_info=True)
- checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
- elif state is not None:
- # Existing printer: trust the live MQTT state rather than opening a
- # second connection (Bambu printers tolerate few concurrent sessions).
- checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
- else:
- checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
- # --- LAN developer mode (only readable over a live MQTT connection) ---
- if state is not None and state.connected:
- if state.developer_mode is True:
- dev_status = "pass"
- elif state.developer_mode is False:
- dev_status = "fail"
- else:
- dev_status = "skip"
- checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
- else:
- checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
- statuses = {c.status for c in checks}
- if "fail" in statuses:
- overall = "problems"
- elif "warn" in statuses:
- overall = "warnings"
- else:
- overall = "ok"
- return PrinterDiagnosticResult(
- printer_id=printer.id if printer else None,
- ip_address=ip_address,
- overall=overall,
- checks=checks,
- )
|