Explain a print that never reached the printer's card, instead of sweeping for it (#2780)

Bambuddy reads a print's 3MF, cover and timelapse over FTPS on port 990,
which on every Bambu model serves external storage only. Under some
configurations H2-series and P2S firmware keeps the sliced file on
internal storage, where Bambu Studio put it over the port-6000 service,
and then no path on 990 can find it.

The print command has always said which of the two it used -- `url` reads
ftp://<name> or brtc://emmc/<name>. We discarded it and swept anyway:
~110 connections per print, all certain to fail, ending in an archive
card with nothing on it and no stated reason. In the reporter's bundle
all 35 dispatches to their H2C and P2S said internal storage, all 25 to
their X1C said external, and all 44 empty cards belonged to the first two.

Read the field, skip the sweep when it cannot succeed, and record which
reason applied. A printer that uses the card is unaffected, and so is one
we have no answer for -- silence is not evidence, and reading it as bad
news would break archives that work today.

The answer is held per print and dropped when that print ends, rather
than kept as a standing fact about the printer. Plenty of prints never
announce themselves: 14 of the 79 print starts in that bundle arrived
with nothing on the request topic, started from the printer's own screen
or picked up after a restart. Left standing, one slicer print to internal
storage would suppress the lookup for every screen-started print after
it, on a printer whose files really are on the card. The sticky reading
is kept for the connection diagnostic alone, which is run after the print
that prompted it and would otherwise have nothing to report.

Two things that pointed the wrong way go with it. The archives banner
told everyone to enable "Store sent files on external storage"; the
reporter had it on for the whole three weeks and it would not have
helped. The diagnostic passed a printer whose slot was empty, because it
read only the toggle -- an empty slot is now a failure naming the slot,
and a printer that has storage and still used its own is a warning. On
P1-series that empty-slot failure yields to the existing unsupported-model
skip: the toggle cannot be switched on there at all, so telling the
operator to insert a card would promise a fix inserting a card does not
deliver (#2524).

Also close FTP sockets on the failure paths, which dropped them for the
garbage collector -- 1813 in a day in that bundle -- and drop the advice
to restart the printer, which the reporter tried twice while a single
manual connection to the same printer handshook cleanly.

This does not make the affected prints archive in full; that needs the
port-6000 protocol tracked in #2762.
This commit is contained in:
maziggy
2026-08-14 13:40:12 +02:00
parent 3954d3a7e6
commit fffa68ec55
34 changed files with 1748 additions and 71 deletions
+39 -11
View File
@@ -31,6 +31,7 @@ from backend.app.schemas.slicer import SliceRequest
from backend.app.services.archive import ArchiveService
from backend.app.services.design_settings import overrides_from_config
from backend.app.services.filament_requirements import annotate_rack_groups
from backend.app.services.print_storage import REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE
from backend.app.utils.http import build_content_disposition
from backend.app.utils.safe_path import safe_join_under
from backend.app.utils.threemf_tools import (
@@ -512,10 +513,23 @@ async def no_3mf_warning(
)
),
):
"""Whether to nudge the user about install step 4 ("Store sent files on
external storage"). True iff any archive in the last 30 days was created
via the no-3MF fallback path — that's the deterministic symptom of the
slicer-side variant of the setting being off.
"""Whether to nudge the user about a print that archived without its 3MF,
and why. True iff any archive in the last 30 days was created via the
no-3MF fallback path.
Also returns ``reason``, because the advice differs and the original
single-cause wording sent people the wrong way. Historically the only
known cause was install step 4 ("Store sent files on external storage")
being off in the slicer, so the banner said so unconditionally. On
H2-series and P2S that advice is actively wrong: the setting is already on
and turning it on again changes nothing, because the printer keeps the
sliced file on internal storage that FTPS does not serve at all (#2780).
``reason`` is the slug from :mod:`print_storage` when we recorded one,
else None for the original slicer-setting case. When archives disagree the
most specific known reason wins — one printer storing internally is a real
finding worth explaining, and it should not be masked by another printer's
plain missing-file fallback.
Complements the connection-diagnostic ``external_storage`` check, which
only catches the printer-side variant of the setting. On older slicers
@@ -536,10 +550,24 @@ async def no_3mf_warning(
if user is not None and not can_read_all:
conditions.append(PrintArchive.created_by_id == user.id)
result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
reasons: set[str] = set()
has_fallback = False
for (extra_data,) in result.all():
if extra_data and extra_data.get("no_3mf_available"):
return {"has_fallback": True}
return {"has_fallback": False}
if not extra_data or not extra_data.get("no_3mf_available"):
continue
has_fallback = True
reason = extra_data.get("no_3mf_reason")
if reason:
reasons.add(reason)
if not has_fallback:
return {"has_fallback": False, "reason": None}
# Most specific first. Archives predating this field carry no reason at
# all, so an install with one H2C and three older printers still gets the
# H2C explanation rather than the generic one.
for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE):
if candidate in reasons:
return {"has_fallback": True, "reason": candidate}
return {"has_fallback": True, "reason": None}
@router.get("/slim", response_model=list[ArchiveSlim])
@@ -2347,14 +2375,14 @@ async def scan_timelapse(
if not files:
# "Couldn't reach the printer" and "the printer has no timelapse
# directory" are different problems with different fixes, and both used
# to come back as one 500 (#2780). A printer whose file service stopped
# answering over TLS needs a restart, and nothing here will work until
# it gets one.
# to come back as one 500 (#2780). Nothing here will work while the
# printer's file service is not answering over TLS, so say that rather
# than reporting an empty directory.
if ftps_handshake_blocked(printer.ip_address):
raise HTTPException(
503,
f"Printer {printer.ip_address} is not answering its file service over TLS. "
"Restart the printer and try again.",
"Bambuddy will try again shortly.",
)
raise HTTPException(404, "No timelapse directory found on the printer")
+15 -1
View File
@@ -51,6 +51,7 @@ from backend.app.services.bambu_ftp import (
get_storage_info_async,
list_files_async,
)
from backend.app.services.print_storage import print_file_reachable_over_ftp
from backend.app.services.printer_diagnostic import run_connection_diagnostic
from backend.app.services.printer_manager import (
display_temperatures,
@@ -1217,6 +1218,19 @@ async def _produce_cover_image(
break
if not downloaded:
# The cover lives inside the 3MF, so it is only reachable if the 3MF is.
# When the printer kept the print on internal storage there is nothing
# at any of these paths, and walking all sixteen of them just to end on
# a 404 that reads as "this print has no cover" helps nobody (#2780).
storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id))
if not storage.reachable:
_cover_404_cache.setdefault(printer_id, set()).add(cache_key)
raise HTTPException(
404,
f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
f"({storage.reason}), so it has no cover to extract.",
)
logger.info(
f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
)
@@ -1234,7 +1248,7 @@ async def _produce_cover_image(
raise HTTPException(
503,
f"Printer {printer.ip_address} is not answering its file service over TLS. "
"Restart the printer and try again.",
"Bambuddy will try again shortly.",
)
try:
downloaded = await download_file_try_paths_async(