| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- """An eMMC dispatch is not proof the file is out of reach (#2856).
- ``print_storage`` reads the ``project_file`` URL: ``brtc://emmc/<name>`` says
- the printer put the sliced file on internal storage, and #2780 turned that into
- "skip the FTPS lookup entirely". On an H2D with a card in the slot that is
- wrong. The reporter's log has ``"url": "brtc://emmc/test.gcode.3mf"`` and, a
- second later, ``Downloaded: /cache/test.gcode.3mf`` -- every one of his prints
- back to 08-12, 19 MB included, until the skip landed and two days of archives
- came out as name-only fallbacks.
- So the URL earns a *bounded probe* rather than a skip. It names the exact file,
- which is one connection walking five directories instead of the sweep's ~110,
- and the printer answers the question instead of a guess about its model. These
- tests pin both halves: the probe runs and its hit is archived normally, and a
- miss still ends in #2780's cheap fallback with its reason intact.
- """
- import logging
- from pathlib import Path
- 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,
- )
- from backend.app.services.print_storage import (
- ftp_probe_paths,
- print_file_reachable_over_ftp,
- probe_filename_from_url,
- )
- 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()
- class TestProbeFilename:
- """What the probe asks for. The dispatch's name is the authoritative one --
- the sweep's guesses are built from ``subtask_name``, which is normalized,
- truncated and occasionally a plate behind."""
- def test_the_reported_case(self):
- assert probe_filename_from_url("brtc://emmc/test.gcode.3mf") == "test.gcode.3mf"
- def test_a_name_with_the_punctuation_users_actually_use(self):
- """Straight from the reporter's log -- ampersands, parentheses, plus."""
- url = "brtc://emmc/H2D_&_H2S_poop_chute+4_buckets_(no_magnets_&_glue).gcode.3mf"
- assert probe_filename_from_url(url) == "H2D_&_H2S_poop_chute+4_buckets_(no_magnets_&_glue).gcode.3mf"
- def test_a_non_ascii_name(self):
- assert probe_filename_from_url("brtc://emmc/小船.gcode.3mf") == "小船.gcode.3mf"
- def test_an_internal_file_path_keeps_only_the_name(self):
- """The model cache is not reachable at that path, but the same file may
- well be sitting in /cache under its bare name."""
- assert probe_filename_from_url("file:///userdata/model/history/Cube.gcode.3mf") == "Cube.gcode.3mf"
- @pytest.mark.parametrize(
- "url",
- [
- None,
- "",
- "Benchy.gcode.3mf", # no scheme -- not a dispatch URL at all
- "brtc://emmc/",
- "brtc://emmc/Benchy.gcode", # a gcode job has no 3MF at any path
- "brtc://emmc/.",
- "brtc://emmc/..",
- 12345,
- ],
- )
- def test_nothing_to_probe_with(self, url):
- assert probe_filename_from_url(url) is None
- @pytest.mark.parametrize(
- "url",
- [
- "brtc://emmc/..\\..\\evil.3mf", # backslash is a separator on the host
- "brtc://emmc/sub\\dir\\Cube.3mf",
- "brtc://emmc/Cube\n.3mf", # control characters
- "brtc://emmc/" + "C" * 256 + ".3mf",
- ],
- )
- def test_a_name_that_could_steer_a_path_is_refused_not_cleaned(self, url):
- """The name comes off the wire and becomes both a remote path and a
- local temp filename, so a name that could point at either somewhere
- else is declined outright -- the print falls back to the archive it
- would have got anyway."""
- assert probe_filename_from_url(url) is None
- class TestProbePaths:
- def test_root_first_then_cache(self):
- """Order is the sweep's own: root is where A1/P1 uploads land (#972),
- /cache is where the H2D keeps its copy (#2856)."""
- assert ftp_probe_paths("Cube.gcode.3mf")[:2] == ["/Cube.gcode.3mf", "/cache/Cube.gcode.3mf"]
- def test_one_filename_five_paths(self):
- assert len(ftp_probe_paths("Cube.gcode.3mf")) == 5
- class TestFindRemoteFile:
- """The lookup the connection diagnostic runs. It must not transfer the
- file -- the thing it is asking about can be tens of megabytes and the
- answer is a yes or a no."""
- @staticmethod
- def _client(listings):
- client = MagicMock()
- client.connect.return_value = True
- client.list_files.side_effect = lambda directory: [
- {"name": name, "is_directory": False} for name in listings.get(directory, [])
- ]
- return client
- @pytest.mark.asyncio
- async def test_finds_the_file_in_cache_and_stops_there(self):
- client = self._client({"/": ["other.3mf"], "/cache": ["test.gcode.3mf"]})
- with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
- from backend.app.services.bambu_ftp import find_remote_file_async
- found = await find_remote_file_async("1.2.3.4", "code", ftp_probe_paths("test.gcode.3mf"))
- assert found == "/cache/test.gcode.3mf"
- # One connection, and the directories after the hit are never listed.
- client.connect.assert_called_once()
- assert [call.args[0] for call in client.list_files.call_args_list] == ["/", "/cache"]
- client.download_to_file.assert_not_called()
- client.disconnect.assert_called_once()
- @pytest.mark.asyncio
- async def test_a_directory_is_listed_once_however_many_candidates_share_it(self):
- client = self._client({})
- with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
- from backend.app.services.bambu_ftp import find_remote_file_async
- found = await find_remote_file_async("1.2.3.4", "code", ["/a.3mf", "/b.3mf", "/cache/a.3mf"])
- assert found is None
- assert [call.args[0] for call in client.list_files.call_args_list] == ["/", "/cache"]
- @pytest.mark.asyncio
- async def test_a_directory_of_the_same_name_is_not_the_file(self):
- client = MagicMock()
- client.connect.return_value = True
- client.list_files.return_value = [{"name": "test.gcode.3mf", "is_directory": True}]
- with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
- from backend.app.services.bambu_ftp import find_remote_file_async
- assert await find_remote_file_async("1.2.3.4", "code", ftp_probe_paths("test.gcode.3mf")) is None
- @pytest.mark.asyncio
- async def test_a_refused_connection_is_not_an_answer(self):
- client = MagicMock()
- client.connect.return_value = False
- with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
- from backend.app.services.bambu_ftp import find_remote_file_async
- assert await find_remote_file_async("1.2.3.4", "code", ftp_probe_paths("test.gcode.3mf")) is None
- client.list_files.assert_not_called()
- class TestVerdictCarriesTheName:
- def test_an_internal_dispatch_carries_a_probe_name(self):
- state = MagicMock(current_project_url="brtc://emmc/test.gcode.3mf", sdcard=True, sdcard_reported=True)
- verdict = print_file_reachable_over_ftp(state)
- assert verdict.reachable is False
- assert verdict.reason == "internal_storage"
- assert verdict.probe_filename == "test.gcode.3mf"
- def test_an_external_dispatch_needs_no_probe(self):
- """It sweeps as it always did; a probe name here would only invite a
- caller to shorten a search that is already working."""
- state = MagicMock(current_project_url="ftp://test.gcode.3mf", sdcard=True, sdcard_reported=True)
- verdict = print_file_reachable_over_ftp(state)
- assert verdict.reachable is True
- assert verdict.probe_filename is None
- def test_an_empty_slot_has_nothing_to_probe(self):
- """No URL and no card: there is no name, and no storage to look on."""
- state = MagicMock(current_project_url=None, sdcard=False, sdcard_reported=True)
- verdict = print_file_reachable_over_ftp(state)
- assert verdict.reason == "no_external_storage"
- assert verdict.probe_filename is None
- def test_an_empty_slot_is_not_probed_even_with_a_name(self):
- """#2780's H2C ran for three weeks with the toggle on and nothing in
- the slot. There is no card for a copy to be on, so the name is not
- worth a connection -- the printer has already answered."""
- state = MagicMock(current_project_url="brtc://emmc/test.gcode.3mf", sdcard=False, sdcard_reported=True)
- verdict = print_file_reachable_over_ftp(state)
- assert verdict.reason == "internal_storage"
- assert verdict.probe_filename is None
- def test_a_printer_that_never_mentions_its_card_is_still_probed(self):
- """`sdcard` defaults to False, so acting on the default would drop the
- probe for every printer that simply does not publish the field."""
- state = MagicMock(current_project_url="brtc://emmc/test.gcode.3mf", sdcard=False, sdcard_reported=False)
- assert print_file_reachable_over_ftp(state).probe_filename == "test.gcode.3mf"
- def _printer():
- printer = MagicMock()
- printer.id = 1
- printer.auto_archive = True
- printer.external_camera_enabled = False
- printer.external_camera_url = None
- # Every unset MagicMock attribute is truthy, and a truthy one here runs the
- # plate-detection camera grab against a printer that does not exist.
- printer.plate_detection_enabled = False
- printer.name = "H2D"
- printer.model = "H2D"
- printer.ip_address = "192.168.1.211"
- printer.access_code = "12345678"
- return printer
- async def _run_print_start(url, *, probe_hit, added, handshake_blocked=False):
- """Drive on_print_start for an eMMC dispatch, returning the probe mock and
- the ArchiveService the success path would have used.
- ``probe_hit`` is the path the probe serves the file from, or None for a
- miss — the download helper returns the winning path rather than a flag so
- the hit can be logged by directory (#1820)."""
- printer = _printer()
- state = MagicMock(current_project_url=url, sdcard=True, sdcard_reported=True)
- 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]))),
- )
- 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)
- probe = AsyncMock(return_value=probe_hit)
- archive_service = MagicMock()
- archive_service.archive_print = AsyncMock(return_value=MagicMock(id=20, print_name="test", status="printing"))
- 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_try_paths_async", new=probe),
- patch("backend.app.main.download_file_async", new=AsyncMock(return_value=False)),
- patch("backend.app.main.with_ftp_retry", new=AsyncMock(return_value=False)),
- patch("backend.app.main.get_cached_3mf", return_value=None),
- patch("backend.app.main.cache_3mf_download") as cache,
- patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
- patch("backend.app.main.ftps_handshake_blocked", return_value=handshake_blocked),
- patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
- patch("backend.app.main.ArchiveService", return_value=archive_service),
- patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
- 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_created = AsyncMock()
- ws.send_archive_updated = AsyncMock()
- relay.on_print_start = AsyncMock()
- relay.on_archive_created = AsyncMock()
- pm.get_status = MagicMock(return_value=state)
- pm.get_client = MagicMock(return_value=None)
- pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST2856"))
- from backend.app.main import on_print_start
- await on_print_start(1, {"filename": "/data/Metadata/plate_1.gcode", "subtask_name": "test"})
- return probe, archive_service, cache
- def _fallback(added):
- for row in added:
- extra = getattr(row, "extra_data", None)
- if isinstance(extra, dict) and extra.get("no_3mf_available"):
- return row
- return None
- class TestPrintStart:
- @pytest.mark.asyncio
- async def test_a_file_the_printer_serves_anyway_is_archived_in_full(self):
- """The reported regression, end to end: eMMC dispatch, file present on
- the card, and the archive gets the real 3MF instead of a name."""
- added = []
- probe, service, cache = await _run_print_start(
- "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added
- )
- probe.assert_awaited_once()
- assert probe.await_args.args[2] == ftp_probe_paths("test.gcode.3mf")
- service.archive_print.assert_awaited_once()
- assert Path(service.archive_print.await_args.kwargs["source_file"]).name == "test.gcode.3mf"
- assert _fallback(added) is None, "a fallback archive here is the bug"
- @pytest.mark.asyncio
- async def test_the_hit_names_the_directory_that_served_it(self, caplog):
- """#1820: a printer that keeps uploads for weeks can serve a same-named
- copy of an earlier slice. Logging only the filename made that mismatch
- invisible; the directory is what makes it diagnosable."""
- added = []
- with caplog.at_level(logging.INFO, logger="backend.app.main"):
- await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added)
- found = [r.getMessage() for r in caplog.records if "even though the printer reported" in r.getMessage()]
- assert found, "the probe hit is not logged at all"
- assert "/cache/test.gcode.3mf" in found[0]
- @pytest.mark.asyncio
- async def test_the_probed_file_is_shared_with_the_cover_endpoint(self):
- """Same 3MF, one transfer. The cover endpoint runs seconds later while
- the frontend opens the card, and re-fetching 19 MB over the printer's
- single FTP socket is what produced #972's 425 storm."""
- added = []
- _probe, _service, cache = await _run_print_start(
- "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added
- )
- cache.assert_called_once()
- assert cache.call_args.args[1] == "test.gcode.3mf"
- @pytest.mark.asyncio
- async def test_a_miss_still_ends_in_the_cheap_fallback(self):
- """#2780's printers are still out there: when the probe finds nothing,
- the reason has to survive to the archive card, which is what stops the
- banner telling an H2C owner to switch on a setting that is already on.
- """
- added = []
- probe, service, _cache = await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=None, added=added)
- probe.assert_awaited_once()
- service.archive_print.assert_not_awaited()
- assert _fallback(added).extra_data["no_3mf_reason"] == "internal_storage"
- @pytest.mark.asyncio
- async def test_no_probe_while_the_file_service_is_in_cool_off(self):
- """The handshake is failing below the path level, so the probe would
- only re-run the failure that put the printer in cool-off (#2780)."""
- added = []
- probe, _service, _cache = await _run_print_start(
- "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added, handshake_blocked=True
- )
- probe.assert_not_awaited()
- assert _fallback(added).extra_data["no_3mf_reason"] == "internal_storage"
- @pytest.mark.asyncio
- async def test_a_cool_off_on_an_emmc_job_schedules_no_retry(self):
- """#2957 added a retry for a cool-off give-up, because that one clears
- in minutes with the file still on the printer. This is not that: the
- file is on internal eMMC and will not appear at any FTPS path however
- long we wait, so the retry must not be scheduled here."""
- from backend.app.main import _fallback_3mf_retry_tasks
- added = []
- before = dict(_fallback_3mf_retry_tasks)
- with patch("backend.app.main._schedule_fallback_3mf_retry") as schedule:
- await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=None, added=added, handshake_blocked=True)
- schedule.assert_not_called()
- assert _fallback_3mf_retry_tasks == before
- @pytest.mark.asyncio
- async def test_a_gcode_job_is_not_probed_for(self):
- """Nothing names a 3MF, so there is no name to ask about and the skip
- stays exactly as cheap as #2780 made it."""
- added = []
- probe, _service, _cache = await _run_print_start(
- "brtc://emmc/plate_1.gcode", probe_hit="/cache/test.gcode.3mf", added=added
- )
- probe.assert_not_awaited()
- assert _fallback(added) is not None
|