Files
bambuddy/backend/app/services/printer_diagnostic.py
T
maziggy 3a5f802cdc fix(diagnostics): read the subnet the host is actually on (issue #3092)
The Network subnet check told the reporter that 192.168.98.170 and
192.168.96.9 were on different networks and to go configure routing
between them. They are four hundred addresses apart inside one
192.168.96.0/22 LAN.

An IPv4 address does not carry its prefix, and the check supplied /24
for both sides. That is the most common LAN and not the only one, and
the guess is wrong in both directions: it splits a /22 and it merges a
/25. Read the prefix off the interface that owns the address instead.

find_local_ipv4_network() enumerates every interface, including the ones
EXCLUDED_INTERFACE_PREFIXES hides. That list keeps docker0 and friends
out of the Virtual Printer's bind dropdown; here the caller is asking
about an address the kernel has already picked as a route source, and
answering "unknown" because it sits on a bridge would be a worse answer
than the truth. When nothing claims the address the check skips, which
is what it always did with no host IP at all -- it must not assert a
split it cannot see.

The same check chose which of Bambuddy's own addresses to compare by
probing a route toward 10.255.255.255, which on a multi-homed host is
not the interface the printer is on. It asks for the route toward the
printer now. On a two-NIC dev box that alone was warning about a printer
sitting on the second card's own subnet.

The probe takes IPv4 literals only. connect() on a name would resolve
it on the event loop, and _same_subnet rejects names anyway, so nothing
is lost. Resolving the prefix shells out to `ip -j addr show`, so it
moves off the loop too.

-----

fix(diagnostics): name the container engine instead of asking about Docker (issue #3092)

"Not running in Docker - not applicable", said to a Bambuddy inside a
Podman container. It reads as "you are on bare metal", and it sent the
reporter looking for his problem somewhere else.

Podman runs Bambuddy in exactly the two shapes Docker does, and the
shape is the thing that breaks printer discovery and the Virtual
Printer. detect_container_runtime() names the engine -- Docker, Podman,
Kubernetes, containerd, LXC, or a container it cannot place -- and the
check became Container network mode.

is_running_in_docker() is deliberately left alone rather than rewritten
on top of it. Three callers key real behaviour off that flag, and one of
them switches the Add Printer flow from SSDP to subnet scanning. SSDP
works for a host-networked Podman container, so answering True there
would take a working feature away to fix a sentence. Widening it is a
separate decision from naming the engine, so it is made separately.

Mode detection keeps the original signal first, which also makes the
Docker path incapable of regressing: a Docker host always has a docker0,
so a container that sees one shares its namespace, and the new rules can
only turn a warning into a pass. That signal says nothing about Podman,
which creates no such interface on a host running no bridge containers --
which is how host networking came to be reported as bridge. The general
form of the same idea answers for Podman: an interface whose iflink
equals its ifindex was created in this namespace, and a NAT-networked
container only ever receives one end of a veth pair. tun/tap is skipped,
because a container may run its own WireGuard and that tun is native to
a namespace it is not evidence of. The interface also has to be the one
the kernel just named -- sysfs is namespace-tagged but a bind-mounted
host /sys is not, and reading a colliding name's numbers would be
reading another namespace's answer.

What is still unreadable now says so and suggests host networking if
discovery is failing, rather than guessing bridge and telling a healthy
install to recreate itself. An LXC or LXD system container is named and
told the question does not apply: it is on the LAN like a small virtual
machine, so there is no network mode to recommend -- and its subnet
check still runs.

An engine we cannot name is a sentinel the frontend localizes, not a
word interpolated into thirteen other languages.

The support bundle carries the engine name beside the Docker flag, so
the next report of this shape is answerable from the bundle.
2026-09-19 12:19:19 +02:00

641 lines
28 KiB
Python
Raw 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, container 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
import ssl
from pathlib import Path
from backend.app.models.printer import Printer
from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
from backend.app.services.bambu_ftp import find_remote_file_async
from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
from backend.app.services.camera import get_camera_port
from backend.app.services.discovery import OCI_RUNTIMES, detect_container_runtime
from backend.app.services.ftp_profiles import get_ftp_profile
from backend.app.services.network_utils import find_local_ipv4_network
from backend.app.services.print_storage import (
REASON_INTERNAL_STORAGE,
StorageVerdict,
ftp_probe_paths,
last_print_storage_verdict,
)
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
# Cap for the storage probe (#2856). One connection and a handful of directory
# listings, so a healthy printer answers in well under a second. Kept short on
# purpose: this check sits inside the support bundle's 15s-per-printer budget,
# and in the interactive run it is spinner time the user is watching.
_STORAGE_PROBE_TIMEOUT = 6.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
# Public alias. The connection watchdog probes the MQTT port before rebuilding a
# client, so it can tell "the printer is switched off" (leave it alone, paho will
# keep retrying) from "the printer is answering but our session is dead" (#2732).
check_port = _check_port
async def _check_ftps_tls(ip: str, model: str | None, timeout: float = _PORT_PROBE_TIMEOUT) -> str:
"""Probe port 990 the way the FTP client does, and say how far it got.
Returns ``"ok"``, ``"closed"`` (nothing accepted the TCP connection) or
``"no_tls"`` (the port accepted the connection but the TLS handshake did
not complete).
A plain TCP probe cannot tell the last two apart, which is exactly how
#2780 hid: the reporter's diagnostic reported port 990 as reachable and
green while every real transfer died in the handshake with
``WRONG_VERSION_NUMBER``, so archives quietly arrived empty with nothing
on screen to explain it.
The context mirrors :class:`~backend.app.services.bambu_ftp.ImplicitFTP_TLS`
-- including the model's TLS cap -- so a pass here means the FTP client
would also get through. Handshake only; no login is attempted, so this
stays valid for the pre-save Add-Printer flow where no access code exists
yet.
"""
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.minimum_version = ssl.TLSVersion.TLSv1_2
if get_ftp_profile(model).cap_tls_v1_2:
context.maximum_version = ssl.TLSVersion.TLSv1_2
writer = None
try:
_reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, PORT_FTPS, ssl=context),
timeout=timeout,
)
return "ok"
except ssl.SSLError:
# The socket was accepted and then failed to negotiate TLS. Reaching
# here at all proves something is listening, so this is never "port
# blocked" -- it is the printer's file service in a state no retry
# gets past.
return "no_tls"
except Exception:
return "closed"
finally:
if writer is not None:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
async def _last_print_file_is_reachable(
printer: Printer | None,
verdict: StorageVerdict,
*,
ftps_ok: bool,
) -> bool:
"""Did the last print's file turn up on external storage after all? (#2856)
``verdict`` is read off the dispatch URL, which says where the printer
*put* the file, not whether port 990 can serve it: an H2D with a card in
the slot reports ``brtc://emmc/<name>`` and then hands the same file over
from ``/cache`` without complaint. Warning that the file is out of reach
while that user's archives are quietly complete would send them chasing a
setting that is already right.
So check before saying it -- one connection, one listing per candidate
directory, no transfer, and only for the check that is about to warn.
"Could not check" returns False and leaves the warning standing, which is
the safe direction: it is a warn, not a fail.
"""
if not ftps_ok or not verdict.probe_filename or printer is None:
return False
# Attribute reads inside the guard too: this also runs from the support
# bundle, where the row may outlive its session, and a detached-instance
# error there is "could not check", not a broken diagnostic.
ip_address = None
try:
ip_address = getattr(printer, "ip_address", None)
access_code = getattr(printer, "access_code", None)
if not ip_address or not access_code:
return False
found = await find_remote_file_async(
ip_address,
access_code,
ftp_probe_paths(verdict.probe_filename),
timeout=_STORAGE_PROBE_TIMEOUT,
socket_timeout=_STORAGE_PROBE_TIMEOUT,
printer_model=getattr(printer, "model", None),
)
except Exception as e:
logger.debug("Could not probe %s for %s: %s", ip_address, verdict.probe_filename, e)
return False
if found:
logger.debug("Last print file is on external storage at %s despite %s", found, verdict.reason)
return found is not None
def _auth_reason_params(reason: str | None) -> dict:
"""Map a client's CONNACK-refusal slug onto the check's `params.reason`.
The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
is present and falls back to the plain per-status text otherwise, so an
unknown or absent slug degrades to today's generic wording rather than a
missing string. Only `auth_rejected` currently carries its own message:
that is the one case where the printer positively told us the credentials
were wrong, as opposed to us merely observing that we are not connected.
"""
if reason == CONNECT_ERROR_AUTH_REJECTED:
return {"reason": CONNECT_ERROR_AUTH_REJECTED}
return {}
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"
# Interfaces a container engine creates on the *host*. Seeing one of them
# means we are in the host's network namespace.
_HOST_INFRA_PREFIXES = ("docker", "br-", "veth", "virbr", "podman", "cni-", "cni_")
def _has_native_interface() -> bool:
"""True if some interface here was created in this network namespace.
A NAT-networked container is handed one end of a veth pair per attached
network, and a veth's ``iflink`` points at its peer's index in the *other*
namespace, so it never equals its own ``ifindex``. An interface where the
two agree was made here — a physical NIC, a bridge, a VLAN — which a
container with its own namespace does not get.
tun/tap devices are skipped: a container can legitimately run its own
WireGuard or Tailscale client, and that tun would otherwise read as
evidence of a namespace it is not evidence of.
"""
try:
entries = [(idx, name) for idx, name in socket.if_nameindex() if name != "lo"]
except Exception:
return False
for index, name in entries:
# Never user input: the kernel's own interface table, and never a path.
iface = Path("/sys/class/net") / name # SEC-PATH-OK: name from socket.if_nameindex()
if (iface / "tun_flags").exists():
continue
try:
ifindex = (iface / "ifindex").read_text().strip()
iflink = (iface / "iflink").read_text().strip()
except (OSError, ValueError):
continue
# sysfs is tagged by network namespace, but a container given a bind
# mount of the host's /sys sees the host's interfaces under names that
# may collide with its own. Reading a different interface's numbers
# would be reading another namespace's answer, so require that the
# entry found here is the one the kernel just named.
if ifindex != str(index):
continue
if ifindex == iflink:
return True
return False
def _detect_container_network_mode(runtime: str | None) -> str | None:
"""Return "host", "bridge", or None when it genuinely cannot be told.
The first rule is the original Docker one and is kept exactly: a Docker
*host* always has a docker0, so a container that can see it shares the
host's namespace. It says nothing about Podman, which on a host running
no bridge containers creates no such interface at all — which is how a
host-networked Podman container came to be told it was on bridge
networking (#3092).
The second rule is the general form of the same idea and is what answers
for Podman. The third is the fallback the first rule always implied: an
OCI container that can see neither is isolated, which is what bridge
networking means.
"""
try:
for _idx, name in socket.if_nameindex():
if name.startswith(_HOST_INFRA_PREFIXES):
return "host"
except Exception:
pass
if _has_native_interface():
return "host"
if runtime in OCI_RUNTIMES:
return "bridge"
return None
def _host_source_ip(destination_ip: str) -> str | None:
"""The local IPv4 address Bambuddy would send from toward ``destination_ip``.
Asking about the printer's own address rather than a fixed far-away one
matters on any host with more than one NIC: the source for a route to the
internet is simply not the source for a route to the printer, and
comparing the printer against the wrong interface is a warning about
nothing (#3092).
Literals only. ``connect()`` on a name would resolve it, and this runs on
the event loop; ``_same_subnet`` rejects names anyway, so nothing is lost.
"""
try:
if ipaddress.ip_address(destination_ip).version != 4:
return None
except ValueError:
return None
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((destination_ip, 1))
return s.getsockname()[0]
finally:
s.close()
except Exception:
# Fail soft: this is a diagnostic, and an unroutable address or an
# exhausted fd table must leave the check skipped, not 500 the page.
return None
def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
"""Is ``printer_ip`` inside the network configured on Bambuddy's ``host_ip``?
None means undeterminable — a name instead of an IPv4 literal, or no
local interface claiming ``host_ip``.
An address does not carry its prefix, and this used to supply ``/24`` for
both sides. That is the most common LAN and not the only one: on the
reporter's ``192.168.96.0/22`` it declared a printer four hundred
addresses away to be on a different network and told him to go configure
routing between two halves of one subnet (#3092). The prefix is read off
the interface that owns the source address instead.
"""
try:
printer_addr = ipaddress.ip_address(printer_ip)
host_addr = ipaddress.ip_address(host_ip)
except ValueError:
return None
if printer_addr.version != 4 or host_addr.version != 4:
return None
network = find_local_ipv4_network(str(host_addr))
if network is None:
return None
return printer_addr in network
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_state, camera_ok = await asyncio.gather(
_check_port(ip_address, PORT_MQTT),
_check_ftps_tls(ip_address, getattr(printer, "model", None) if printer else None),
_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"))
# "no_tls" gets its own message: the port is open, so the usual advice
# (unblock port 990) is wrong and only a printer restart helps (#2780).
checks.append(
DiagnosticCheck(
id="port_ftps",
status="pass" if ftps_state == "ok" else "warn",
params={} if ftps_state != "no_tls" else {"reason": "no_tls"},
)
)
checks.append(
DiagnosticCheck(
id="port_rtsps",
status="pass" if camera_ok else "warn",
params={"port": camera_port, "protocol": camera_protocol},
)
)
# --- Container network mode ---
# Not Docker-only: Podman runs Bambuddy in exactly the same two shapes and
# its users were told "Not running in Docker", which reads as "you are on
# bare metal" and sent them looking for the problem somewhere else (#3092).
runtime = detect_container_runtime()
network_mode: str | None = None
if runtime is None:
checks.append(DiagnosticCheck(id="network_mode", status="skip"))
elif runtime not in OCI_RUNTIMES:
# An LXC/LXD system container is bridged onto the LAN like a small VM.
# There is no network mode to recommend, so don't imply there is one.
checks.append(
DiagnosticCheck(id="network_mode", status="skip", params={"reason": "system_container", "runtime": runtime})
)
else:
network_mode = _detect_container_network_mode(runtime)
if network_mode is None:
checks.append(
DiagnosticCheck(id="network_mode", status="skip", params={"reason": "unknown", "runtime": runtime})
)
else:
checks.append(
DiagnosticCheck(
id="network_mode",
status="pass" if network_mode == "host" else "warn",
params={"mode": network_mode, "runtime": runtime},
)
)
# --- 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 = _host_source_ip(ip_address)
# Off the loop: resolving the prefix shells out to `ip -j addr show`.
same = await asyncio.to_thread(_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 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.
#
# Ahead of the empty-slot check below on purpose (#2524 over #2780):
# on a P1-series the toggle cannot be switched on at all, so telling
# the operator to insert a card would promise a fix that inserting a
# card does not deliver.
checks.append(
DiagnosticCheck(
id="external_storage",
status="skip",
params={"reason": "unsupported_model"},
)
)
elif getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
# The toggle can be on and still achieve nothing with an empty slot,
# and that combination used to report a clean pass — #2780's H2C had
# `store_to_sdcard` set and `sdcard` False for three solid weeks while
# every one of its archives came out blank. Report the empty slot,
# which is the part the operator can actually act on.
checks.append(
DiagnosticCheck(
id="external_storage",
status="fail",
params={"reason": "no_media"},
)
)
elif not (last_verdict := last_print_storage_verdict(state)).reachable and not await _last_print_file_is_reachable(
printer, last_verdict, ftps_ok=ftps_state == "ok"
):
# The toggle is on, a card is in, the printer said the last print's file
# is on internal storage — and a probe confirmed it really is out of
# reach. That is what H2-series and P2S firmware does, and no setting
# here changes it (#2762 tracks reading that storage). A pass here would
# be a lie; a fail would be unresolvable.
#
# The verdict's own reason, not a fixed one: a print started from the
# printer's screen reaches this branch too, and it never involved a
# slicer, so the advice attached to REASON_INTERNAL_STORAGE would name a
# dialog its operator never opened (#1820).
checks.append(
DiagnosticCheck(
id="external_storage",
status="warn",
params={"reason": last_verdict.reason or REASON_INTERNAL_STORAGE},
)
)
elif not last_verdict.reachable:
# Reached only when the probe above found the file: the printer named
# internal storage and served it over FTPS anyway. What this check is
# for is whether Bambuddy can read the print file, and it demonstrably
# can — so pass, whatever the toggle happens to say (#2856).
checks.append(DiagnosticCheck(id="external_storage", status="pass"))
elif store_to_sdcard is True:
checks.append(DiagnosticCheck(id="external_storage", status="pass"))
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",
params=_auth_reason_params(result.get("reason")),
)
)
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).
# `connected == False` alone does not say *why* — the live client keeps
# the last CONNACK refusal, so a rejected access code can be reported as
# such instead of as a generic failure the user has to guess at (#2698).
client = printer_manager.get_client(printer.id) if printer else None
checks.append(
DiagnosticCheck(
id="mqtt_auth",
status="pass" if state.connected else "fail",
params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
)
)
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,
)