Files
bambuddy/backend/app/services/printer_diagnostic.py
maziggy 6127e30abf fix(diagnostic): skip external-storage check on P1S/P1P instead of fail (#2524)
P1-series printers have a MicroSD slot but no reachable control to enable
"Store sent files on external storage": current P1 firmware (through
01.10.00.00) never publishes support_save_remote_print_file_to_storage, so
the Bambu Studio toggle never renders, and the P1S has no screen — leaving
store_to_sdcard stuck False with no way for the user to change it. The
external_storage check reported a permanently-unresolvable fail.

Add NO_REMOTE_STORAGE_TOGGLE_MODELS (P1S, P1P) + has_remote_storage_toggle(),
kept distinct from the no-slot NO_EXTERNAL_STORAGE_MODELS. When a model has a
slot but no reachable toggle and the option is off, the check now emits skip
with params reason=unsupported_model rather than fail, and overall no longer
escalates. A P1S reporting the option on still passes. Model-scoped and
default-open, so X1/P2S/H2 (where the fail is actionable) are unaffected; if
a future firmware surfaces the capability, drop the model and it reactivates.
The frontend DiagnosticChecklist renders a reason-specific message variant
(external_storage.skip_unsupported_model) so P1 users see an accurate
explanation instead of the generic "needs a live MQTT connection" skip text.
The fix propagates to the support-bundle diagnostic snapshot automatically.
2026-07-09 08:43:59 +02:00

334 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.camera import get_camera_port
from backend.app.services.discovery import is_running_in_docker
from backend.app.services.printer_manager import printer_manager
from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
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_CHAMBER_IMAGE = 6000 # Chamber image protocol — A1/P1 camera stream; optional.
_PORT_PROBE_TIMEOUT = 3.0
# Default seconds the `printer_publishing` check will wait for the first
# report-topic message before declaring fail. Bambu printers in idle publish
# push_status every few seconds; 10s catches healthy bridges with margin while
# staying short enough that the spinner-with-countdown UX stays acceptable.
# The check exits the moment a message arrives, so the typical wall-clock is
# 1–2s, not the full 10. Passed as ``wait_for_publish_seconds`` per call so
# the support-package code path can skip the wait entirely (defaults to 0).
PUBLISH_WAIT_DEFAULT = 10.0
_PUBLISH_POLL_INTERVAL = 0.5
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 _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
"""Return the model-specific camera diagnostic port and display protocol."""
if not printer:
return PORT_RTSPS, "RTSPS"
model = getattr(printer, "model", None)
if not model:
return PORT_RTSPS, "RTSPS"
camera_port = get_camera_port(model)
if camera_port == PORT_CHAMBER_IMAGE:
return camera_port, "Chamber Image"
return camera_port, "RTSPS"
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,
wait_for_publish_seconds: float = 0.0,
) -> 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) ---
camera_port, camera_protocol = _camera_port_for_printer(printer)
mqtt_ok, ftps_ok, camera_ok = await asyncio.gather(
_check_port(ip_address, PORT_MQTT),
_check_port(ip_address, PORT_FTPS),
_check_port(ip_address, camera_port),
)
# MQTT is connection-critical; FTPS/camera 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 camera_ok else "warn",
params={"port": camera_port, "protocol": camera_protocol},
)
)
# --- 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},
)
)
# --- External storage (printer-side "Store sent files on external storage") ---
# Install step 4. The setting has two variants depending on
# firmware/slicer combo: on newer firmware the toggle lives on the
# printer (P2S 01.02 / BambuStudio 2.6+), on older versions it's
# purely a slicer-side preference.
#
# For the printer-side variant, `home_flag` bit 11 is pushed on every
# status report and parsed into state.store_to_sdcard (bambu_mqtt.py
# line 153). That's the signal here — instant, no FTP I/O.
#
# For the slicer-side variant, the printer never hears about it and
# this check will pass even when the user is missing step 4. That gap
# is covered separately by the "no_3mf_available" archive-fallback
# banner. An FTP upload-and-verify probe was tried and rejected — the
# /cache directory is always writable from Bambuddy regardless of
# either toggle, so the probe always passes and detects nothing.
#
# Skip entirely on models with no external-storage slot at all (A1
# and A1 Mini). They never set home_flag bit 11, so a naive read of
# `store_to_sdcard` would fall through to a false `fail` for every
# A1-series user (#1703).
#
# Some models (P1-series) DO have a slot but no reachable control to turn
# the option on: the Bambu Studio toggle only appears when the printer
# publishes `support_save_remote_print_file_to_storage`, which current
# P1 firmware never does, and the P1S/P1P have no screen. For those,
# `store_to_sdcard` is stuck False with no way to fix it — report `skip`
# (with a reason the UI explains) instead of a permanently-red `fail`
# (#2524).
state = printer_manager.get_status(printer.id) if printer else None
model = getattr(printer, "model", None) if printer else None
model_has_slot = has_external_storage(model) if printer else True
store_to_sdcard = getattr(state, "store_to_sdcard", None) if state else None
if not model_has_slot or state is None or not state.connected:
checks.append(DiagnosticCheck(id="external_storage", status="skip"))
elif store_to_sdcard is True:
checks.append(DiagnosticCheck(id="external_storage", status="pass"))
elif store_to_sdcard is False and not has_remote_storage_toggle(model):
# Slot present but no way to enable it on this firmware — don't nag
# with an unresolvable fail; explain why via the reason param.
checks.append(
DiagnosticCheck(
id="external_storage",
status="skip",
params={"reason": "unsupported_model"},
)
)
elif store_to_sdcard is False:
checks.append(DiagnosticCheck(id="external_storage", status="fail"))
else:
# State exists but the field was never populated — skip rather than
# report a false fail.
checks.append(DiagnosticCheck(id="external_storage", status="skip"))
# --- MQTT credentials / connection ---
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"))
# --- Printer is actually publishing on its report topic ---
# The mqtt_auth check above only proves TCP + TLS + auth + SUBSCRIBE
# succeed. A printer with a wrong-cased serial — or one that simply isn't
# publishing for some other reason — still passes mqtt_auth because the
# broker accepts the subscription regardless. The user-visible symptom in
# that case is "AMS / K-profiles / custom filaments missing on the slicer
# side": the VP bridge has nothing cached to mirror because no reports
# arrived. #1622 surfaced this: bridge keep-alive timeouts paired with
# the `Connected and subscribed, but the printer has sent zero status
# reports` warning. The check below turns that warning into a structured
# diagnostic result the user can act on without grepping container logs.
#
# If ``_report_messages_since_connect`` is already > 0, we exit
# immediately — the bridge has seen reports. If it's 0 and a wait is
# requested, we poll every PUBLISH_POLL_INTERVAL up to
# ``wait_for_publish_seconds`` so a fresh reconnect (counter reset to 0)
# isn't reported as fail before the printer's first idle push lands.
publishing_params: dict[str, int | float] | None = None
publishing_status = "skip"
if printer is not None and state is not None and state.connected:
client = printer_manager.get_client(printer.id)
if client is not None:
wait_budget = max(wait_for_publish_seconds, 0.0)
if wait_budget > 0:
# Expose the budget so the UI can render a countdown next to
# the spinner — the user knows how long this check might take.
publishing_params = {"max_wait_seconds": wait_budget}
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_budget
while True:
if client.report_messages_since_connect > 0:
publishing_status = "pass"
break
if loop.time() >= deadline:
publishing_status = "fail"
break
await asyncio.sleep(_PUBLISH_POLL_INTERVAL)
checks.append(
DiagnosticCheck(
id="printer_publishing",
status=publishing_status,
params=publishing_params or {},
)
)
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,
)