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
+98 -1
View File
@@ -4181,7 +4181,20 @@ class TestCoverWhenFileServiceIsWedged:
@pytest.mark.integration
async def test_returns_503_naming_the_file_service(self, async_client: AsyncClient, printer_factory, db_session):
printer = await printer_factory(name="Wedged P2S")
state = MagicMock(subtask_name="job", state="RUNNING", gcode_file=None)
# Spell out the storage fields rather than leaving them to MagicMock:
# every attribute of a bare mock is truthy, so the storage gate added
# in #2780 would read this stub as "the print is on internal storage"
# and answer 404 before the handshake check ever ran. This test is
# about a printer whose file service stopped answering, so say that
# its last print went to the card.
state = MagicMock(
subtask_name="job",
state="RUNNING",
gcode_file=None,
current_project_url="ftp://job.gcode.3mf",
sdcard=True,
sdcard_reported=True,
)
with (
patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
@@ -4199,3 +4212,87 @@ class TestCoverWhenFileServiceIsWedged:
assert "TLS" in response.json()["detail"]
# The whole point: no FTP fan-out against a printer that cannot answer.
mock_download.assert_not_called()
class TestCoverWhenThePrintIsOnInternalStorage:
"""The cover lives inside the 3MF, so it is only reachable if the 3MF is.
#2780: on an H2C or P2S the sliced file routinely stays on internal
storage, which FTPS does not serve. Walking all sixteen candidate paths to
arrive at a bare 404 spends connections the printer is already short of
and tells the user "this print has no cover", which is not what happened.
"""
@pytest.fixture(autouse=True)
def _clear_404_cache(self):
"""The route remembers 404s per printer+print so repeat requests skip
the fan-out. It is module-level, printer ids restart at 1 for each
test, and both tests here use the same subtask name -- so without this
the second test reads the first one's cached miss and never reaches
the code it is checking.
"""
from backend.app.api.routes.printers import _cover_404_cache
_cover_404_cache.clear()
yield
_cover_404_cache.clear()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_skips_the_fan_out_and_says_why(self, async_client: AsyncClient, printer_factory, db_session):
printer = await printer_factory(name="H2C")
state = MagicMock(
subtask_name="job",
state="RUNNING",
gcode_file=None,
current_project_url="brtc://emmc/job.gcode.3mf",
sdcard=True,
sdcard_reported=True,
)
with (
patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=False),
patch(
"backend.app.api.routes.printers.download_file_try_paths_async",
new=AsyncMock(return_value=False),
) as mock_download,
):
response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
assert response.status_code == 404, response.text
assert "internal_storage" in response.json()["detail"]
mock_download.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_print_on_external_storage_still_fans_out(
self, async_client: AsyncClient, printer_factory, db_session
):
"""The regression guard. Every install whose covers work today reaches
the download exactly as before."""
printer = await printer_factory(name="X1C")
state = MagicMock(
subtask_name="job",
state="RUNNING",
gcode_file=None,
current_project_url="ftp://job.gcode.3mf",
sdcard=True,
sdcard_reported=True,
)
with (
patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=False),
patch(
"backend.app.api.routes.printers.download_file_try_paths_async",
new=AsyncMock(return_value=False),
) as mock_download,
):
await async_client.get(f"/api/v1/printers/{printer.id}/cover")
mock_download.assert_called()