mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
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:
@@ -731,7 +731,7 @@ class TestNo3MFWarning:
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"has_fallback": True}
|
||||
assert response.json() == {"has_fallback": True, "reason": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -739,7 +739,7 @@ class TestNo3MFWarning:
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"has_fallback": False}
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -755,7 +755,7 @@ class TestNo3MFWarning:
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"has_fallback": False}
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -775,7 +775,7 @@ class TestNo3MFWarning:
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"has_fallback": False}
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
# Sanity: row really is in the DB, we just don't surface it.
|
||||
assert (await db_session.get(PrintArchive, archive.id)) is not None
|
||||
|
||||
@@ -796,7 +796,99 @@ class TestNo3MFWarning:
|
||||
assert response.status_code == 200
|
||||
# Soft-deleted fallbacks have been actioned (user clearing the
|
||||
# evidence). Stop nudging.
|
||||
assert response.json() == {"has_fallback": False}
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
|
||||
|
||||
class TestNo3MFWarningReason:
|
||||
"""Which explanation the banner shows (#2780).
|
||||
|
||||
The endpoint used to return a bare boolean and the banner had one wording:
|
||||
"Store sent files on external storage" is off in the slicer, go turn it on.
|
||||
For H2-series and P2S that is wrong twice over -- the setting is already on
|
||||
and turning it on changes nothing, because the printer keeps the file on
|
||||
internal storage FTPS does not serve. Sending people to re-do a step that
|
||||
cannot help is worse than saying nothing, so the reason travels with the
|
||||
flag.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_internal_storage_is_reported(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"},
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_empty_slot_is_reported(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"},
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_known_reason_outranks_archives_that_carry_none(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Every archive written before this field exists carries no reason,
|
||||
and a farm usually has more than one printer. One H2C among four
|
||||
printers should still get the H2C explanation rather than have it
|
||||
drowned out by older rows.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True})
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_internal_storage_outranks_an_empty_slot(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Both are real, and only one of them is surprising. "Put a card in"
|
||||
is advice the user can act on unprompted; "this model keeps prints
|
||||
somewhere Bambuddy cannot read" is the one they need told.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"})
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_reason_on_a_non_fallback_archive_is_not_reported(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""The reason only means anything alongside the flag it explains."""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, extra_data={"no_3mf_reason": "internal_storage"})
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning")
|
||||
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
|
||||
|
||||
class TestPrintLogEntryDelete:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1689,9 +1689,15 @@ class TestHandshakeCoolOff:
|
||||
assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is True
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert any("WRONG_VERSION_NUMBER" in m for m in messages)
|
||||
# The warning has to name the remedy, not just the error: the port is
|
||||
# open, so "unblock port 990" is the wrong advice.
|
||||
assert any("restarted" in m for m in messages)
|
||||
# The warning has to say more than the error: the port is open, so
|
||||
# "unblock port 990" is the wrong reading and the operator needs to
|
||||
# know what it actually costs them.
|
||||
assert any("covers and timelapses cannot be fetched" in m for m in messages)
|
||||
# It must NOT prescribe a restart. It used to, and #2780's reporter
|
||||
# power-cycled both affected printers with no effect while a single
|
||||
# manual connection to the same printers completed a clean handshake.
|
||||
# Naming a remedy that is known not to work is worse than naming none.
|
||||
assert not any("restart" in m.lower() for m in messages)
|
||||
|
||||
def test_blocked_printer_is_not_contacted_again(self, plaintext_server):
|
||||
self._client(plaintext_server).connect()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""A connection that never came up still has to be closed (#2780).
|
||||
|
||||
Every failure path in ``BambuFTPClient.connect`` used to clear ``self._ftp``
|
||||
and stop there, leaving a connected socket for the garbage collector to
|
||||
notice. Once, that is untidy. At the volume this code runs at it is not: a
|
||||
single print used to walk ~110 candidate paths, so a printer refusing FTPS got
|
||||
~110 sockets opened and dropped in a couple of minutes, and one support bundle
|
||||
recorded 1813 in a day.
|
||||
|
||||
That matters beyond tidiness, because the leading explanation for the refusal
|
||||
is the printer running out of connection slots -- a single manual connect to
|
||||
the same printer completes a clean handshake while Bambuddy is failing, and
|
||||
vsFTPd answers a per-source limit in cleartext, which is exactly the
|
||||
``WRONG_VERSION_NUMBER`` we see. If that is right, abandoning sockets is not a
|
||||
side effect of the problem, it is part of what sustains it.
|
||||
|
||||
These tests assert the socket is closed, not merely dereferenced, because
|
||||
dereferencing is what the old code did and it looked identical from outside.
|
||||
"""
|
||||
|
||||
import ftplib
|
||||
import ssl
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.bambu_ftp import BambuFTPClient
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_cooloff():
|
||||
"""The SSL path opens a per-IP cool-off that outlives the test."""
|
||||
BambuFTPClient._handshake_blocked_until.clear()
|
||||
yield
|
||||
BambuFTPClient._handshake_blocked_until.clear()
|
||||
|
||||
|
||||
def _client(ip="192.168.1.210"):
|
||||
return BambuFTPClient(ip, "12345678", printer_model="P2S")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
# The two the #2780 bundle actually recorded, 1813 and 49 times.
|
||||
ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"),
|
||||
TimeoutError("_ssl.c:1015: The handshake operation timed out"),
|
||||
ftplib.error_perm("530 Login incorrect."),
|
||||
ftplib.error_temp("421 Service not available."),
|
||||
OSError("Connection reset by peer"),
|
||||
],
|
||||
ids=["ssl", "timeout", "perm", "temp", "oserror"],
|
||||
)
|
||||
def test_a_failed_connect_closes_its_socket(error):
|
||||
fake_ftp = MagicMock()
|
||||
fake_ftp.connect.side_effect = error
|
||||
|
||||
with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
|
||||
client = _client()
|
||||
assert client.connect() is False
|
||||
|
||||
fake_ftp.close.assert_called_once()
|
||||
# Never QUIT: that is a command, and there is no working control channel
|
||||
# to send it on.
|
||||
fake_ftp.quit.assert_not_called()
|
||||
assert client._ftp is None
|
||||
|
||||
|
||||
def test_a_failure_after_connect_still_closes():
|
||||
"""Login and prot_p run on a live socket, so a failure there leaks a
|
||||
genuinely established connection -- the worst case for a session limit."""
|
||||
fake_ftp = MagicMock()
|
||||
fake_ftp.login.side_effect = ftplib.error_perm("530 Login incorrect.")
|
||||
|
||||
with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
|
||||
client = _client()
|
||||
assert client.connect() is False
|
||||
|
||||
fake_ftp.close.assert_called_once()
|
||||
|
||||
|
||||
def test_close_raising_does_not_propagate():
|
||||
"""Cleanup is best-effort; the socket may already be gone. A raise here
|
||||
would turn a handled connect failure into an unhandled one."""
|
||||
fake_ftp = MagicMock()
|
||||
fake_ftp.connect.side_effect = OSError("boom")
|
||||
fake_ftp.close.side_effect = OSError("already closed")
|
||||
|
||||
with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
|
||||
assert _client().connect() is False
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
def test_a_healthy_disconnect_quits(self):
|
||||
fake_ftp = MagicMock()
|
||||
|
||||
with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
|
||||
client = _client()
|
||||
assert client.connect() is True
|
||||
client.disconnect()
|
||||
|
||||
fake_ftp.quit.assert_called_once()
|
||||
assert client._ftp is None
|
||||
|
||||
def test_a_failing_quit_falls_back_to_closing(self):
|
||||
"""``ftplib.FTP.quit`` sends QUIT and only then closes, so when the
|
||||
send raises it never reaches its own close and the socket stays open.
|
||||
The old handler swallowed that exception and left it there.
|
||||
"""
|
||||
fake_ftp = MagicMock()
|
||||
fake_ftp.quit.side_effect = OSError("Broken pipe")
|
||||
|
||||
with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
|
||||
client = _client()
|
||||
assert client.connect() is True
|
||||
client.disconnect()
|
||||
|
||||
fake_ftp.close.assert_called_once()
|
||||
assert client._ftp is None
|
||||
|
||||
|
||||
def test_the_ssl_path_still_opens_the_cooloff():
|
||||
"""The cleanup change must not disturb the gate that stops the storm."""
|
||||
fake_ftp = MagicMock()
|
||||
fake_ftp.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
|
||||
|
||||
with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
|
||||
client = _client("192.168.1.211")
|
||||
assert client.connect() is False
|
||||
|
||||
assert BambuFTPClient.handshake_blocked("192.168.1.211") is True
|
||||
@@ -40,11 +40,25 @@ def _port_probe(overrides=None):
|
||||
return _probe
|
||||
|
||||
|
||||
def _state(*, connected=True, developer_mode=True, store_to_sdcard=True):
|
||||
def _state(
|
||||
*,
|
||||
connected=True,
|
||||
developer_mode=True,
|
||||
store_to_sdcard=True,
|
||||
sdcard=True,
|
||||
sdcard_reported=False,
|
||||
last_project_url=None,
|
||||
):
|
||||
# sdcard_reported defaults False — "the printer never mentioned its card",
|
||||
# which is what every pre-#2780 test in this file assumes and what keeps
|
||||
# the storage branches out of their way.
|
||||
return types.SimpleNamespace(
|
||||
connected=connected,
|
||||
developer_mode=developer_mode,
|
||||
store_to_sdcard=store_to_sdcard,
|
||||
sdcard=sdcard,
|
||||
sdcard_reported=sdcard_reported,
|
||||
last_project_url=last_project_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -459,6 +473,88 @@ class TestExternalStorageCheck:
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
|
||||
assert _statuses(result)["external_storage"] == "pass"
|
||||
|
||||
# --- #2780: the toggle being on is not the same as it working ---------
|
||||
|
||||
async def test_fails_when_the_toggle_is_on_but_the_slot_is_empty(self):
|
||||
"""#2780's H2C reported `store_to_sdcard` set and `sdcard` False for
|
||||
three solid weeks. The check called that a clean pass while every one
|
||||
of its 23 archives came out blank -- the toggle can be on and still
|
||||
achieve nothing with nothing in the slot.
|
||||
"""
|
||||
with _Env(state=_state(store_to_sdcard=True, sdcard=False, sdcard_reported=True)):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="H2C"))
|
||||
check = next(c for c in result.checks if c.id == "external_storage")
|
||||
assert check.status == "fail"
|
||||
assert check.params == {"reason": "no_media"}
|
||||
assert result.overall == "problems"
|
||||
|
||||
async def test_warns_when_the_printer_kept_the_print_internally(self):
|
||||
"""Toggle on, card in, and the printer still used internal storage --
|
||||
which is what H2-series and P2S firmware does. A pass would be a lie
|
||||
and a fail would be unresolvable, so it warns."""
|
||||
state = _state(
|
||||
store_to_sdcard=True,
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
last_project_url="brtc://emmc/Benchy.gcode.3mf",
|
||||
)
|
||||
with _Env(state=state):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="H2C"))
|
||||
check = next(c for c in result.checks if c.id == "external_storage")
|
||||
assert check.status == "warn"
|
||||
assert check.params == {"reason": "internal_storage"}
|
||||
assert result.overall == "warnings"
|
||||
|
||||
async def test_an_external_storage_dispatch_still_passes(self):
|
||||
"""The regression guard: an X1C dispatch says `ftp://`, and that must
|
||||
read exactly as it did before any of this existed."""
|
||||
state = _state(
|
||||
store_to_sdcard=True,
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
last_project_url="ftp://Benchy.gcode.3mf",
|
||||
)
|
||||
with _Env(state=state):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
|
||||
assert _statuses(result)["external_storage"] == "pass"
|
||||
|
||||
async def test_a_p1_series_keeps_its_non_nagging_skip_with_no_card(self):
|
||||
"""#2524 outranks #2780 here. Current P1 firmware exposes no way to
|
||||
turn the option on, so "insert a card" would promise a fix that
|
||||
inserting a card does not deliver -- the unresolvable-fail nagging
|
||||
that #2524 removed, back under a different label.
|
||||
"""
|
||||
with _Env(state=_state(store_to_sdcard=False, sdcard=False, sdcard_reported=True)):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
|
||||
check = next(c for c in result.checks if c.id == "external_storage")
|
||||
assert check.status == "skip"
|
||||
assert check.params == {"reason": "unsupported_model"}
|
||||
assert result.overall == "ok"
|
||||
|
||||
async def test_a_model_with_a_reachable_toggle_does_report_the_empty_slot(self):
|
||||
"""The counterpart: on an X1C the advice is actionable, so give it."""
|
||||
with _Env(state=_state(store_to_sdcard=False, sdcard=False, sdcard_reported=True)):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
|
||||
check = next(c for c in result.checks if c.id == "external_storage")
|
||||
assert check.status == "fail"
|
||||
assert check.params == {"reason": "no_media"}
|
||||
|
||||
async def test_an_empty_slot_outranks_the_internal_storage_warning(self):
|
||||
"""Both apply on a stickless H2C. "Put a card in" is the one the
|
||||
operator can act on, so it must not be masked by the softer warning.
|
||||
"""
|
||||
state = _state(
|
||||
store_to_sdcard=True,
|
||||
sdcard=False,
|
||||
sdcard_reported=True,
|
||||
last_project_url="brtc://emmc/Benchy.gcode.3mf",
|
||||
)
|
||||
with _Env(state=state):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="H2C"))
|
||||
check = next(c for c in result.checks if c.id == "external_storage")
|
||||
assert check.status == "fail"
|
||||
assert check.params == {"reason": "no_media"}
|
||||
|
||||
|
||||
class TestFtpsTlsProbe:
|
||||
"""The FTPS probe must reach the handshake, not stop at the TCP accept.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Capturing where the printer put the sliced file (#2780).
|
||||
|
||||
The ``project_file`` command has always carried a ``url`` saying which storage
|
||||
the file landed on -- ``ftp://<name>`` for the card, ``brtc://emmc/<name>`` for
|
||||
the printer's internal storage. Bambuddy discarded it and swept FTPS regardless,
|
||||
which on an H2C or P2S is ~110 connections that cannot succeed followed by a
|
||||
blank archive card with no stated reason.
|
||||
|
||||
These tests pin the capture itself. The decision made from it lives in
|
||||
:mod:`backend.app.services.print_storage` and is tested separately.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client():
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
return BambuMQTTClient(ip_address="192.168.1.210", serial_number="TEST2780", access_code="12345678")
|
||||
|
||||
|
||||
class TestProjectUrlCapture:
|
||||
def test_nothing_is_claimed_before_a_dispatch_is_seen(self, mqtt_client):
|
||||
"""None is the "we don't know" answer, and consumers depend on it
|
||||
being distinguishable from a known-bad value."""
|
||||
assert mqtt_client.state.current_project_url is None
|
||||
|
||||
def test_an_internal_storage_dispatch_is_recorded(self, mqtt_client):
|
||||
"""The shape every H2C and P2S dispatch had in #2780's bundle."""
|
||||
mqtt_client._handle_request_message(
|
||||
{
|
||||
"print": {
|
||||
"command": "project_file",
|
||||
"url": "brtc://emmc/169356_204314.STEP.gcode.3mf",
|
||||
"sequence_id": "20002",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert mqtt_client.state.current_project_url == "brtc://emmc/169356_204314.STEP.gcode.3mf"
|
||||
|
||||
def test_our_own_dispatch_is_recorded_too(self, mqtt_client):
|
||||
"""We publish to the request topic and subscribe to it, so our own
|
||||
commands come back. Capturing them is wanted, not incidental: after a
|
||||
Bambuddy-launched print the file really is on external storage, and a
|
||||
stale internal-storage URL from the slicer's last job would otherwise
|
||||
suppress a sweep that would have worked.
|
||||
|
||||
``sequence_id`` 20000 is ours; the external-payload log line keys off
|
||||
that, and this capture must not.
|
||||
"""
|
||||
mqtt_client._handle_request_message(
|
||||
{"print": {"command": "project_file", "url": "ftp://Benchy.gcode.3mf", "sequence_id": "20000"}}
|
||||
)
|
||||
|
||||
assert mqtt_client.state.current_project_url == "ftp://Benchy.gcode.3mf"
|
||||
|
||||
def test_the_latest_dispatch_wins(self, mqtt_client):
|
||||
for url in ("brtc://emmc/first.3mf", "ftp://second.3mf"):
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": url}})
|
||||
|
||||
assert mqtt_client.state.current_project_url == "ftp://second.3mf"
|
||||
|
||||
def test_a_dispatch_without_a_url_leaves_the_last_one_alone(self, mqtt_client):
|
||||
"""Better a slightly stale answer than a wrongly-cleared one: clearing
|
||||
would drop us to "unknown" and re-run the sweep we just learned was
|
||||
pointless."""
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "ams_mapping": [0]}})
|
||||
|
||||
assert mqtt_client.state.current_project_url == "brtc://emmc/x.3mf"
|
||||
|
||||
@pytest.mark.parametrize("url", [None, "", 12345, [], {}])
|
||||
def test_a_non_string_url_is_ignored(self, mqtt_client, url):
|
||||
"""Straight off the wire, so it is whatever the sender sent."""
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": url}})
|
||||
|
||||
assert mqtt_client.state.current_project_url is None
|
||||
|
||||
def test_other_commands_do_not_touch_it(self, mqtt_client):
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "ftp://kept.3mf"}})
|
||||
for command in ("pause", "resume", "stop", "gcode_line"):
|
||||
mqtt_client._handle_request_message({"print": {"command": command, "url": "ftp://ignored.3mf"}})
|
||||
|
||||
assert mqtt_client.state.current_project_url == "ftp://kept.3mf"
|
||||
|
||||
|
||||
class TestTheTwoFieldsHaveDifferentLifetimes:
|
||||
"""``current_project_url`` describes the print now running and is cleared
|
||||
when it ends; ``last_project_url`` is sticky, for reporting after the fact.
|
||||
|
||||
The clearing is the load-bearing half. In #2780's support bundle 14 of 79
|
||||
print starts arrived with no ``project_file`` on the request topic at all
|
||||
-- touchscreen reprints and restart recovery. If those inherited the
|
||||
previous job's destination, one Studio print to internal storage would
|
||||
suppress the FTPS sweep for every screen-started print after it.
|
||||
"""
|
||||
|
||||
def test_a_dispatch_sets_both(self, mqtt_client):
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
|
||||
|
||||
assert mqtt_client.state.current_project_url == "brtc://emmc/x.3mf"
|
||||
assert mqtt_client.state.last_project_url == "brtc://emmc/x.3mf"
|
||||
|
||||
def test_finishing_a_print_clears_only_the_per_print_one(self, mqtt_client):
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
|
||||
# Drive a full RUNNING -> FINISH transition so the real completion
|
||||
# path runs, rather than reaching in and clearing the field by hand.
|
||||
mqtt_client.on_print_complete = lambda data: None
|
||||
mqtt_client._update_state({"gcode_state": "RUNNING"})
|
||||
mqtt_client._update_state({"gcode_state": "FINISH"})
|
||||
|
||||
assert mqtt_client.state.current_project_url is None, (
|
||||
"a print Bambuddy saw no dispatch for must read as unknown, not inherit this one"
|
||||
)
|
||||
assert mqtt_client.state.last_project_url == "brtc://emmc/x.3mf", (
|
||||
"the diagnostic runs after the print that prompted it and still needs the answer"
|
||||
)
|
||||
|
||||
def test_an_aborted_print_clears_it_too(self, mqtt_client):
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
|
||||
mqtt_client.on_print_complete = lambda data: None
|
||||
mqtt_client._update_state({"gcode_state": "RUNNING"})
|
||||
mqtt_client._update_state({"gcode_state": "FAILED"})
|
||||
|
||||
assert mqtt_client.state.current_project_url is None
|
||||
|
||||
def test_the_regression_sequence_end_to_end(self, mqtt_client):
|
||||
"""Studio print to internal storage, then a print started from the
|
||||
printer's own screen.
|
||||
|
||||
The capture and the gate are pinned separately; this is the sequence
|
||||
that made the split necessary, walked through both at once. The second
|
||||
print's file may well be on the card -- it has to be swept for, not
|
||||
written off on the strength of the first print's destination.
|
||||
"""
|
||||
from backend.app.services.print_storage import print_file_reachable_over_ftp
|
||||
|
||||
mqtt_client._update_state({"sdcard": True})
|
||||
mqtt_client.on_print_complete = lambda data: None
|
||||
|
||||
# 1. Studio dispatches to internal storage and the print runs.
|
||||
mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/studio.3mf"}})
|
||||
mqtt_client._update_state({"gcode_state": "RUNNING"})
|
||||
assert print_file_reachable_over_ftp(mqtt_client.state).reachable is False
|
||||
|
||||
# 2. It finishes.
|
||||
mqtt_client._update_state({"gcode_state": "FINISH"})
|
||||
|
||||
# 3. The operator reprints from the touchscreen. No project_file
|
||||
# reaches the request topic at all -- 14 of 79 print starts in the
|
||||
# reporter's bundle looked exactly like this.
|
||||
mqtt_client._update_state({"gcode_state": "RUNNING"})
|
||||
|
||||
assert print_file_reachable_over_ftp(mqtt_client.state).reachable, (
|
||||
"the screen-started print inherited the Studio print's destination and lost its archive"
|
||||
)
|
||||
|
||||
|
||||
class TestSdcardReported:
|
||||
"""`sdcard` defaults to False, so "no card" and "never said" look alike.
|
||||
|
||||
Every consumer that treats False as evidence needs the difference, and
|
||||
getting it wrong skips FTP sweeps for printers whose archives work fine.
|
||||
"""
|
||||
|
||||
def test_nothing_is_claimed_before_a_status_frame(self, mqtt_client):
|
||||
assert mqtt_client.state.sdcard_reported is False
|
||||
assert mqtt_client.state.sdcard is False
|
||||
|
||||
def test_a_frame_carrying_the_field_marks_it_reported(self, mqtt_client):
|
||||
mqtt_client._update_state({"sdcard": False})
|
||||
|
||||
assert mqtt_client.state.sdcard_reported is True
|
||||
assert mqtt_client.state.sdcard is False
|
||||
|
||||
def test_a_frame_without_the_field_does_not(self, mqtt_client):
|
||||
mqtt_client._update_state({"gcode_state": "RUNNING"})
|
||||
|
||||
assert mqtt_client.state.sdcard_reported is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
(True, True),
|
||||
(1, True),
|
||||
("HAS_SDCARD_NORMAL", True),
|
||||
("normal", True),
|
||||
("HAS_SDCARD_ABNORMAL", True),
|
||||
(False, False),
|
||||
(0, False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_the_reported_flag_is_set_for_every_accepted_shape(self, mqtt_client, raw, expected):
|
||||
"""Whatever the field means, having seen it is what is recorded."""
|
||||
mqtt_client._update_state({"sdcard": raw})
|
||||
|
||||
assert mqtt_client.state.sdcard_reported is True
|
||||
assert mqtt_client.state.sdcard is expected
|
||||
@@ -0,0 +1,219 @@
|
||||
"""The 3MF sweep is skipped when it cannot succeed, and the card says why (#2780).
|
||||
|
||||
The unit tests around ``print_storage`` pin the decision; this pins that
|
||||
``on_print_start`` actually acts on it. Two things have to hold and neither is
|
||||
visible from the helper alone:
|
||||
|
||||
* No FTP is attempted. The sweep is six filename variants across five
|
||||
directories with up to four retries, and on the reporter's P2S every one of
|
||||
those failed -- 1813 failures in a day against a printer that, on the leading
|
||||
theory, was refusing precisely because of the connection volume.
|
||||
* The fallback archive records *which* reason applied. Without it the archives
|
||||
banner falls back to its original wording, which tells the user to switch on
|
||||
a setting that in this case is already on and would not have helped.
|
||||
|
||||
The regression case matters as much: a printer whose file service is fine must
|
||||
still sweep exactly as before.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.main import (
|
||||
_active_prints,
|
||||
_expected_print_creators,
|
||||
_expected_print_registered_at,
|
||||
_expected_prints,
|
||||
_print_ams_mappings,
|
||||
_timelapse_baselines,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_dicts():
|
||||
for d in (
|
||||
_expected_prints,
|
||||
_expected_print_registered_at,
|
||||
_expected_print_creators,
|
||||
_print_ams_mappings,
|
||||
_active_prints,
|
||||
_timelapse_baselines,
|
||||
):
|
||||
d.clear()
|
||||
yield
|
||||
for d in (
|
||||
_expected_prints,
|
||||
_expected_print_registered_at,
|
||||
_expected_print_creators,
|
||||
_print_ams_mappings,
|
||||
_active_prints,
|
||||
_timelapse_baselines,
|
||||
):
|
||||
d.clear()
|
||||
|
||||
|
||||
def _printer():
|
||||
printer = MagicMock()
|
||||
printer.id = 1
|
||||
printer.auto_archive = True
|
||||
printer.external_camera_enabled = False
|
||||
printer.external_camera_url = None
|
||||
# Spell this one out: every unset MagicMock attribute is truthy, so leaving
|
||||
# it implicit runs the plate-detection camera grab and each test waits out
|
||||
# its 10s timeout against a printer that does not exist.
|
||||
printer.plate_detection_enabled = False
|
||||
printer.name = "H2C"
|
||||
printer.model = "H2C"
|
||||
printer.ip_address = "192.168.1.210"
|
||||
printer.access_code = "12345678"
|
||||
return printer
|
||||
|
||||
|
||||
def _state(current_project_url, sdcard=True, sdcard_reported=True):
|
||||
return MagicMock(
|
||||
current_project_url=current_project_url,
|
||||
sdcard=sdcard,
|
||||
sdcard_reported=sdcard_reported,
|
||||
)
|
||||
|
||||
|
||||
async def _run_print_start(state, added):
|
||||
"""Drive on_print_start for a print with no matching archive, capturing
|
||||
whatever rows it adds and whether it reached the FTP layer.
|
||||
"""
|
||||
printer = _printer()
|
||||
|
||||
def execute_router(stmt, *args, **kwargs):
|
||||
sql = str(stmt).lower()
|
||||
if "from printers" in sql or "from printer " in sql:
|
||||
return MagicMock(
|
||||
scalar_one_or_none=MagicMock(return_value=printer),
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
|
||||
)
|
||||
# No existing / expected archive anywhere: the fallback path is the
|
||||
# one under test.
|
||||
return MagicMock(
|
||||
scalar_one_or_none=MagicMock(return_value=None),
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
|
||||
)
|
||||
|
||||
session = AsyncMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock()
|
||||
session.execute = AsyncMock(side_effect=execute_router)
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.add = MagicMock(side_effect=added.append)
|
||||
|
||||
download = AsyncMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch("backend.app.main.async_session") as session_maker,
|
||||
patch("backend.app.main.notification_service") as notif,
|
||||
patch("backend.app.main.smart_plug_manager") as plug,
|
||||
patch("backend.app.main.ws_manager") as ws,
|
||||
patch("backend.app.main.mqtt_relay") as relay,
|
||||
patch("backend.app.main.printer_manager") as pm,
|
||||
patch("backend.app.main.download_file_async", new=download),
|
||||
patch("backend.app.main.with_ftp_retry", new=AsyncMock(return_value=False)) as retry,
|
||||
patch("backend.app.main.get_cached_3mf", return_value=None),
|
||||
# Imported inside the function, so it has to be patched at its source
|
||||
# or the directory-search fallback opens real sockets to a printer
|
||||
# that is not there and the test hangs on connect timeouts.
|
||||
patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
|
||||
patch("backend.app.main.ftps_handshake_blocked", return_value=False),
|
||||
patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
|
||||
patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
|
||||
patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
|
||||
patch("backend.app.main._maybe_start_layer_timelapse"),
|
||||
patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
|
||||
):
|
||||
session_maker.return_value = session
|
||||
notif.on_print_start = AsyncMock()
|
||||
plug.on_print_start = AsyncMock()
|
||||
ws.send_print_start = AsyncMock()
|
||||
ws.send_archive_updated = AsyncMock()
|
||||
relay.on_print_start = AsyncMock()
|
||||
pm.get_status = MagicMock(return_value=state)
|
||||
pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST2780"))
|
||||
|
||||
from backend.app.main import on_print_start
|
||||
|
||||
await on_print_start(
|
||||
1,
|
||||
{"filename": "/data/Metadata/plate_1.gcode", "subtask_name": "Halterung"},
|
||||
)
|
||||
|
||||
return download, retry
|
||||
|
||||
|
||||
def _fallback(added):
|
||||
"""The archive row the fallback path created, if any."""
|
||||
for row in added:
|
||||
extra = getattr(row, "extra_data", None)
|
||||
if isinstance(extra, dict) and extra.get("no_3mf_available"):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_print_on_internal_storage_never_touches_ftp():
|
||||
added = []
|
||||
|
||||
download, retry = await _run_print_start(_state("brtc://emmc/Halterung.gcode.3mf"), added)
|
||||
|
||||
download.assert_not_called()
|
||||
retry.assert_not_called()
|
||||
|
||||
archive = _fallback(added)
|
||||
assert archive is not None, "the print still has to be archived, just without slice data"
|
||||
assert archive.extra_data["no_3mf_reason"] == "internal_storage"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_slot_never_touches_ftp():
|
||||
added = []
|
||||
|
||||
download, retry = await _run_print_start(_state(None, sdcard=False, sdcard_reported=True), added)
|
||||
|
||||
download.assert_not_called()
|
||||
retry.assert_not_called()
|
||||
assert _fallback(added).extra_data["no_3mf_reason"] == "no_external_storage"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_print_on_external_storage_still_sweeps():
|
||||
"""The regression guard: every install whose archives work today reaches
|
||||
the download exactly as it did before the gate existed."""
|
||||
added = []
|
||||
|
||||
download, _ = await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
|
||||
|
||||
download.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_printer_that_said_nothing_still_sweeps():
|
||||
"""Silence is not evidence. A printer whose broker refuses the request
|
||||
topic never gives us a URL, and its firmware may never publish `sdcard`
|
||||
either -- that install must behave exactly as before."""
|
||||
added = []
|
||||
|
||||
download, _ = await _run_print_start(_state(None, sdcard=False, sdcard_reported=False), added)
|
||||
|
||||
download.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_sweep_that_simply_found_nothing_records_no_reason():
|
||||
"""The original cause -- the slicer left no file on a card that is present
|
||||
and working -- is still reported with the original wording, because that
|
||||
advice is right for it."""
|
||||
added = []
|
||||
|
||||
await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
|
||||
|
||||
assert _fallback(added).extra_data["no_3mf_reason"] is None
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Which prints are worth an FTPS sweep, and which are not (#2780).
|
||||
|
||||
The gate this module guards is one-sided on purpose, and both sides matter:
|
||||
|
||||
* Missing it costs ~110 doomed FTP connections per print and an archive card
|
||||
that is blank with no stated reason -- the reported bug.
|
||||
* Over-applying it costs archives that work today. A printer that never
|
||||
publishes ``sdcard`` and never had a ``project_file`` reach us must sweep
|
||||
exactly as before, or the fix is a regression for everyone else.
|
||||
|
||||
So the tests below spend most of their weight on the second failure mode.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.print_storage import (
|
||||
REASON_INTERNAL_STORAGE,
|
||||
REASON_NO_EXTERNAL_STORAGE,
|
||||
external_storage_present,
|
||||
last_print_storage_verdict,
|
||||
print_file_reachable_over_ftp,
|
||||
url_is_external_storage,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class FakeState:
|
||||
"""Stand-in for PrinterState with only the fields the helper reads."""
|
||||
|
||||
def __init__(self, current_project_url=None, sdcard=False, sdcard_reported=False, last_project_url=None):
|
||||
self.current_project_url = current_project_url
|
||||
# Defaults to the per-print value: for every test that does not care
|
||||
# about the distinction, the two readings agree.
|
||||
self.last_project_url = current_project_url if last_project_url is None else last_project_url
|
||||
self.sdcard = sdcard
|
||||
self.sdcard_reported = sdcard_reported
|
||||
|
||||
|
||||
class TestUrlScheme:
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"ftp://Benchy.gcode.3mf",
|
||||
# Real dispatches carry names with spaces and non-ASCII; the scheme
|
||||
# is all that is being read and none of that should disturb it.
|
||||
"ftp://Halterung Kühlschrank V2.gcode.3mf",
|
||||
"FTP://Benchy.gcode.3mf",
|
||||
],
|
||||
)
|
||||
def test_ftp_means_external_storage(self, url):
|
||||
assert url_is_external_storage(url) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
# The scheme every H2C and P2S dispatch in #2780's bundle carried,
|
||||
# 35 out of 35.
|
||||
"brtc://emmc/169356_204314.STEP.gcode.3mf",
|
||||
"brtc://emmc/Benchy.gcode.3mf",
|
||||
],
|
||||
)
|
||||
def test_brtc_means_internal_storage(self, url):
|
||||
assert url_is_external_storage(url) is False
|
||||
|
||||
def test_an_unknown_scheme_is_not_assumed_reachable(self):
|
||||
"""Matching the reachable value, not the unreachable one.
|
||||
|
||||
If Bambu ships a third scheme, the safe reading is "somewhere we can't
|
||||
see", not "fine" -- an unrecognised scheme that read as reachable would
|
||||
put the storm straight back.
|
||||
"""
|
||||
assert url_is_external_storage("sftp://Benchy.3mf") is False
|
||||
|
||||
@pytest.mark.parametrize("url", [None, "", "Benchy.gcode.3mf"])
|
||||
def test_no_usable_url_declines_to_answer(self, url):
|
||||
"""None is a third answer and must not collapse into False."""
|
||||
assert url_is_external_storage(url) is None
|
||||
|
||||
@pytest.mark.parametrize("url", [12345, [], {}, object()])
|
||||
def test_a_non_string_url_declines_too(self, url):
|
||||
"""The value arrives straight off the wire, so it is whatever the
|
||||
sender put there. Truth-testing alone would let a non-string fall
|
||||
through to the scheme comparison and read as internal storage --
|
||||
which is a silent skip of a sweep that should have run.
|
||||
"""
|
||||
assert url_is_external_storage(url) is None
|
||||
|
||||
|
||||
class TestSweepIsSkipped:
|
||||
def test_a_print_kept_on_internal_storage(self):
|
||||
verdict = print_file_reachable_over_ftp(FakeState(current_project_url="brtc://emmc/Benchy.gcode.3mf"))
|
||||
|
||||
assert verdict.reachable is False
|
||||
assert verdict.reason == REASON_INTERNAL_STORAGE
|
||||
|
||||
def test_a_printer_that_says_its_slot_is_empty(self):
|
||||
"""#2780's H2C: `sdcard` False for three weeks, 800 clean FTPS
|
||||
connections, and a 550 on every single path it asked for."""
|
||||
verdict = print_file_reachable_over_ftp(FakeState(sdcard=False, sdcard_reported=True))
|
||||
|
||||
assert verdict.reachable is False
|
||||
assert verdict.reason == REASON_NO_EXTERNAL_STORAGE
|
||||
|
||||
|
||||
class TestSweepStillRuns:
|
||||
"""The regression guard. Every case here worked before the gate existed."""
|
||||
|
||||
def test_a_print_on_external_storage(self):
|
||||
assert print_file_reachable_over_ftp(FakeState(current_project_url="ftp://Benchy.gcode.3mf")).reachable
|
||||
|
||||
def test_a_printer_that_never_mentioned_its_card(self):
|
||||
"""Silence is not evidence.
|
||||
|
||||
`sdcard` defaults to False, so a printer whose firmware simply never
|
||||
publishes the field looks identical to an empty slot unless the
|
||||
"did it ever say so" flag is honoured. Reading the default as an
|
||||
answer would skip the sweep for every one of them.
|
||||
"""
|
||||
assert print_file_reachable_over_ftp(FakeState(sdcard=False, sdcard_reported=False)).reachable
|
||||
|
||||
def test_a_printer_with_a_card_and_no_dispatch_seen(self):
|
||||
"""Some brokers refuse the request-topic subscription, so no URL ever
|
||||
arrives. That install must behave exactly as it did before."""
|
||||
assert print_file_reachable_over_ftp(FakeState(sdcard=True, sdcard_reported=True)).reachable
|
||||
|
||||
def test_an_explicit_ftp_url_outranks_a_disagreeing_card_flag(self):
|
||||
"""A false skip is a regression; a needless sweep is only slow.
|
||||
|
||||
When the dispatcher says the file went to external storage, believe
|
||||
the specific claim over the general one.
|
||||
"""
|
||||
state = FakeState(current_project_url="ftp://Benchy.gcode.3mf", sdcard=False, sdcard_reported=True)
|
||||
|
||||
assert print_file_reachable_over_ftp(state).reachable
|
||||
|
||||
def test_no_state_at_all(self):
|
||||
"""Printer not connected, or status not yet populated."""
|
||||
assert print_file_reachable_over_ftp(None).reachable
|
||||
|
||||
def test_a_state_missing_the_fields_entirely(self):
|
||||
"""The helper is duck-typed, and a PrinterState from a pickled or
|
||||
partially-constructed source may predate these fields."""
|
||||
|
||||
class Bare:
|
||||
pass
|
||||
|
||||
assert print_file_reachable_over_ftp(Bare()).reachable
|
||||
|
||||
|
||||
class TestReasonIsAlwaysPresentWhenUnreachable:
|
||||
@pytest.mark.parametrize(
|
||||
"state",
|
||||
[
|
||||
FakeState(current_project_url="brtc://emmc/x.3mf"),
|
||||
FakeState(sdcard=False, sdcard_reported=True),
|
||||
],
|
||||
)
|
||||
def test_unreachable_carries_a_reason(self, state):
|
||||
"""The reason crosses into the API and picks the banner text. An
|
||||
unreachable verdict without one would render the generic advice --
|
||||
which is the wrong advice, and the whole point of the change."""
|
||||
verdict = print_file_reachable_over_ftp(state)
|
||||
|
||||
assert verdict.reachable is False
|
||||
assert verdict.reason
|
||||
|
||||
def test_reachable_carries_no_reason(self):
|
||||
assert print_file_reachable_over_ftp(FakeState(sdcard=True, sdcard_reported=True)).reason is None
|
||||
|
||||
|
||||
class TestTheGateUsesThePerPrintUrlOnly:
|
||||
"""A stale URL must never gate a sweep.
|
||||
|
||||
``current_project_url`` is cleared when a print ends; ``last_project_url``
|
||||
is sticky for reporting. The gate reads only the first, and that is
|
||||
load-bearing rather than tidiness: 18% of the print starts in #2780's
|
||||
support bundle (14 of 79) had no ``project_file`` on the request topic at
|
||||
all -- touchscreen reprints, restart recovery, anything Bambuddy did not
|
||||
see dispatched. If those inherited the previous job's destination, a
|
||||
printer that ran one Studio print to internal storage would skip the FTPS
|
||||
sweep for every subsequent screen-started print, losing archives that work
|
||||
today.
|
||||
|
||||
The asymmetry is what makes it worth pinning: a stale ``ftp://`` costs
|
||||
only a pointless sweep, while a stale ``brtc://`` costs an archive.
|
||||
"""
|
||||
|
||||
def test_a_print_with_no_dispatch_of_its_own_still_sweeps(self):
|
||||
"""The previous print went to internal storage; this one Bambuddy
|
||||
never saw dispatched. Unknown, so sweep."""
|
||||
state = FakeState(
|
||||
current_project_url=None,
|
||||
last_project_url="brtc://emmc/previous.gcode.3mf",
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
)
|
||||
|
||||
assert print_file_reachable_over_ftp(state).reachable
|
||||
|
||||
def test_the_sticky_reading_still_reports_it(self):
|
||||
"""The diagnostic is normally run after the print that prompted it, so
|
||||
it needs the answer the gate has rightly forgotten."""
|
||||
state = FakeState(
|
||||
current_project_url=None,
|
||||
last_project_url="brtc://emmc/previous.gcode.3mf",
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
)
|
||||
|
||||
verdict = last_print_storage_verdict(state)
|
||||
|
||||
assert verdict.reachable is False
|
||||
assert verdict.reason == REASON_INTERNAL_STORAGE
|
||||
|
||||
def test_an_empty_slot_is_reported_by_both(self):
|
||||
"""Not URL-derived, so clearing the per-print value changes nothing."""
|
||||
state = FakeState(sdcard=False, sdcard_reported=True)
|
||||
|
||||
assert print_file_reachable_over_ftp(state).reason == REASON_NO_EXTERNAL_STORAGE
|
||||
assert last_print_storage_verdict(state).reason == REASON_NO_EXTERNAL_STORAGE
|
||||
|
||||
def test_the_sticky_reading_never_gates_a_sweep(self):
|
||||
"""Guard against someone swapping the two back: if the gate ever reads
|
||||
the sticky field, the case above starts failing -- and so does this."""
|
||||
import inspect
|
||||
|
||||
from backend.app.services import print_storage
|
||||
|
||||
source = inspect.getsource(print_storage.print_file_reachable_over_ftp)
|
||||
assert "current_project_url" in source
|
||||
assert "last_project_url" not in source.split('"""')[-1]
|
||||
|
||||
|
||||
class TestTimelapseUsesTheNarrowerRule:
|
||||
"""The printer writes its timelapse to the card itself.
|
||||
|
||||
Where the *sliced file* went says nothing about whether a video exists, so
|
||||
gating the timelapse scan on the URL would silently stop finding videos on
|
||||
every H2C and P2S that has a card in -- a new bug, introduced by the fix
|
||||
for this one.
|
||||
"""
|
||||
|
||||
def test_internal_storage_does_not_suppress_the_timelapse_scan(self):
|
||||
state = FakeState(current_project_url="brtc://emmc/x.3mf", sdcard=True, sdcard_reported=True)
|
||||
|
||||
assert print_file_reachable_over_ftp(state).reachable is False
|
||||
assert external_storage_present(state) is True
|
||||
|
||||
def test_an_empty_slot_does_suppress_it(self):
|
||||
assert external_storage_present(FakeState(sdcard=False, sdcard_reported=True)) is False
|
||||
|
||||
def test_silence_does_not(self):
|
||||
assert external_storage_present(FakeState(sdcard=False, sdcard_reported=False)) is True
|
||||
|
||||
def test_no_state_does_not(self):
|
||||
assert external_storage_present(None) is True
|
||||
Reference in New Issue
Block a user