mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
fix(printers): show correct plate thumbnail on multi-plate 3MFs (#1166)
P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
The /cover route's regex falls back to plate 1 — and the printer card
shows the wrong plate's thumbnail on multi-plate prints.
Resolution order in the new resolve_plate_id() helper (used by both
the status route's current_plate_id and /cover):
1. The plate Bambuddy dispatched. start_print() now records
(dispatched_plate_id, dispatched_subtask) on PrinterState; the
subtask check rejects stale records from a previous Bambuddy
dispatch bleeding into a Studio-direct print on the same project.
2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
for firmware that does include the path).
3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
covers per-plate archives sliced separately in Studio without a
Bambuddy dispatch record.
4. Default to plate 1.
Cover-byte cache key simplified to (subtask_name, view_key) now that
plate resolution is late-bound. clear_cover_cache() already fires on
every print start, so re-dispatches with a different plate always
fetch a fresh thumbnail.
Bambuddy-dispatched prints additionally register the local archive
3MF in the cover cache at dispatch time, so /cover reads straight
from the archive directory and doesn't refetch the file over FTP
from a printer whose FTP server is busy serving the active print.
Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
dispatch record on start_print, 2 integration tests for the cover
route (dispatch wins over plate-1 default; 3MF-scan fallback for
per-plate archive without dispatch record).
This commit is contained in:
@@ -301,6 +301,94 @@ class TestPrintersAPI:
|
||||
assert result["fila_switch"]["stat"] == 0
|
||||
assert result["fila_switch"]["info"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
|
||||
self, async_client: AsyncClient, printer_factory, db_session, tmp_path
|
||||
):
|
||||
"""When firmware drops the plate path from gcode_file (e.g. P1S
|
||||
01.10.00.00, #1166), the dispatched-plate record must take precedence
|
||||
and serve plate 4's thumbnail instead of falling back to plate_1.png."""
|
||||
import io
|
||||
import zipfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from backend.app.services.bambu_ftp import cache_3mf_download
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
|
||||
printer = await printer_factory()
|
||||
|
||||
# Build a 3MF that mimics a "true" multi-plate archive: thumbnails
|
||||
# for plates 1..4 are all present, gcode files for plates 1..4 are
|
||||
# all present. Without the dispatch record we'd default to plate_1.png.
|
||||
threemf_path = tmp_path / "MyModel.3mf"
|
||||
with zipfile.ZipFile(threemf_path, "w") as zf:
|
||||
for plate in range(1, 5):
|
||||
zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
|
||||
zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
|
||||
|
||||
cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
|
||||
|
||||
state = PrinterState()
|
||||
state.connected = True
|
||||
state.state = "RUNNING"
|
||||
state.subtask_name = "MyModel"
|
||||
state.gcode_file = "MyModel.3mf" # firmware drops plate path
|
||||
state.dispatched_plate_id = 4
|
||||
state.dispatched_subtask = "MyModel"
|
||||
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_status = MagicMock(return_value=state)
|
||||
mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"PLATE_4_PNG"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cover_3mf_scan_fallback_for_per_plate_archive(
|
||||
self, async_client: AsyncClient, printer_factory, db_session, tmp_path
|
||||
):
|
||||
"""Per-plate archives sliced separately in Bambu Studio contain a
|
||||
single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
|
||||
for every plate. With no dispatch record (e.g. dispatched via Studio
|
||||
directly) and no plate path in gcode_file, the route must scan the
|
||||
3MF and pick plate N's thumbnail. See #1166 option 4."""
|
||||
import zipfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from backend.app.services.bambu_ftp import cache_3mf_download
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
|
||||
printer = await printer_factory()
|
||||
|
||||
# Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
|
||||
threemf_path = tmp_path / "PerPlate.3mf"
|
||||
with zipfile.ZipFile(threemf_path, "w") as zf:
|
||||
for plate in range(1, 5):
|
||||
zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
|
||||
zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
|
||||
|
||||
cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
|
||||
|
||||
state = PrinterState()
|
||||
state.connected = True
|
||||
state.state = "RUNNING"
|
||||
state.subtask_name = "PerPlate"
|
||||
state.gcode_file = "PerPlate.3mf"
|
||||
# No dispatch record (Studio-direct dispatch).
|
||||
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_status = MagicMock(return_value=state)
|
||||
mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"PLATE_3_PNG"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_printer_status_omits_fila_switch_when_not_installed(
|
||||
|
||||
@@ -4393,6 +4393,75 @@ class TestHardResetClientDirect:
|
||||
assert mqtt_client._client is None
|
||||
|
||||
|
||||
class TestStartPrintRecordsDispatchedPlate:
|
||||
"""Tests for the dispatched-plate record set by start_print() — used by the
|
||||
/cover route to pick the right thumbnail when the printer's gcode_file
|
||||
echo doesn't include the plate path (#1166).
|
||||
|
||||
Some firmware versions (P1S 01.10.00.00) only put the .3mf filename in
|
||||
print.gcode_file, so the regex falls back to plate 1 and the printer card
|
||||
shows the wrong plate's thumbnail. Recording what we dispatched at the
|
||||
publish site lets resolve_plate_id() return the right plate without
|
||||
needing to introspect the 3MF.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TEST123",
|
||||
access_code="12345678",
|
||||
)
|
||||
client._client = MagicMock()
|
||||
client.state.connected = True
|
||||
return client
|
||||
|
||||
def test_dispatched_plate_recorded_after_start_print(self, mqtt_client):
|
||||
# Default state has no dispatched plate.
|
||||
assert mqtt_client.state.dispatched_plate_id is None
|
||||
assert mqtt_client.state.dispatched_subtask is None
|
||||
|
||||
mqtt_client.start_print("Luigi.3mf", plate_id=2)
|
||||
|
||||
# The subtask_name we record matches the one we send (and the printer
|
||||
# reflects back via MQTT), so resolve_plate_id() can validate the
|
||||
# match downstream.
|
||||
assert mqtt_client.state.dispatched_plate_id == 2
|
||||
assert mqtt_client.state.dispatched_subtask == "Luigi"
|
||||
|
||||
def test_dispatched_plate_default_is_one(self, mqtt_client):
|
||||
# When start_print is called without plate_id (legacy/single-plate
|
||||
# flow), we still record plate=1 — the contract is that dispatched_*
|
||||
# describes the active dispatch.
|
||||
mqtt_client.start_print("Single.3mf")
|
||||
assert mqtt_client.state.dispatched_plate_id == 1
|
||||
assert mqtt_client.state.dispatched_subtask == "Single"
|
||||
|
||||
def test_dispatched_plate_overwritten_by_subsequent_dispatch(self, mqtt_client):
|
||||
# Each dispatch replaces the prior record so we can never serve a
|
||||
# stale plate from an older print.
|
||||
mqtt_client.start_print("First.3mf", plate_id=4)
|
||||
mqtt_client.start_print("Second.3mf", plate_id=2)
|
||||
|
||||
assert mqtt_client.state.dispatched_plate_id == 2
|
||||
assert mqtt_client.state.dispatched_subtask == "Second"
|
||||
|
||||
def test_dispatched_plate_not_recorded_when_publish_skipped(self, mqtt_client):
|
||||
# If start_print early-returns because we're not connected, no record
|
||||
# should land — otherwise the next print's /cover call would believe
|
||||
# a phantom dispatch happened.
|
||||
mqtt_client.state.connected = False
|
||||
result = mqtt_client.start_print("Phantom.3mf", plate_id=3)
|
||||
|
||||
assert result is False
|
||||
assert mqtt_client.state.dispatched_plate_id is None
|
||||
assert mqtt_client.state.dispatched_subtask is None
|
||||
|
||||
|
||||
class TestFilamentTrackSwitchDetection:
|
||||
"""Tests for Filament Track Switch (FTS) accessory detection (#1162).
|
||||
|
||||
|
||||
@@ -1418,3 +1418,85 @@ class TestParsePlateId:
|
||||
# wins. This matches real Bambu paths where the segment is preceded by
|
||||
# arbitrary directory noise, and matches the equivalent frontend regex.
|
||||
assert parse_plate_id("/uploads/project/plate_5.gcode.md5") == 5
|
||||
|
||||
|
||||
class TestResolvePlateId:
|
||||
"""Tests for resolve_plate_id() — plate resolution with dispatch precedence.
|
||||
|
||||
Regression coverage for #1166: P1S firmware 01.10.00.00 only puts the .3mf
|
||||
filename in print.gcode_file, so parse_plate_id() returns None and the
|
||||
printer card falls back to plate 1. When Bambuddy dispatches the print
|
||||
itself we know the right plate; resolve_plate_id() prefers that record over
|
||||
the gcode_file regex when subtask_name matches.
|
||||
"""
|
||||
|
||||
def _make_state(self, **kwargs):
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
|
||||
state = PrinterState()
|
||||
for k, v in kwargs.items():
|
||||
setattr(state, k, v)
|
||||
return state
|
||||
|
||||
def test_dispatched_plate_wins_when_subtask_matches(self):
|
||||
# User dispatches plate 4 via Bambuddy. Printer reflects subtask_name
|
||||
# but firmware drops the plate path from gcode_file. Without the dispatch
|
||||
# record we'd default to plate 1.
|
||||
from backend.app.services.printer_manager import resolve_plate_id
|
||||
|
||||
state = self._make_state(
|
||||
gcode_file="MyModel.3mf", # No plate path — firmware bug
|
||||
subtask_name="MyModel",
|
||||
dispatched_plate_id=4,
|
||||
dispatched_subtask="MyModel",
|
||||
)
|
||||
assert resolve_plate_id(state) == 4
|
||||
|
||||
def test_dispatched_ignored_when_subtask_differs(self):
|
||||
# Bambuddy's dispatch record is for a previous print; the printer is
|
||||
# now running a different subtask (Studio-direct dispatch). The stale
|
||||
# record must not be used — fall back to gcode_file regex.
|
||||
from backend.app.services.printer_manager import resolve_plate_id
|
||||
|
||||
state = self._make_state(
|
||||
gcode_file="/Metadata/plate_2.gcode",
|
||||
subtask_name="DifferentPrint",
|
||||
dispatched_plate_id=4,
|
||||
dispatched_subtask="MyModel",
|
||||
)
|
||||
assert resolve_plate_id(state) == 2
|
||||
|
||||
def test_falls_back_to_gcode_regex_without_dispatch(self):
|
||||
# Studio-direct dispatch — no Bambuddy dispatch record. Existing logic
|
||||
# (parse_plate_id on gcode_file) must still work.
|
||||
from backend.app.services.printer_manager import resolve_plate_id
|
||||
|
||||
state = self._make_state(
|
||||
gcode_file="/Metadata/plate_3.gcode",
|
||||
subtask_name="MyModel",
|
||||
)
|
||||
assert resolve_plate_id(state) == 3
|
||||
|
||||
def test_returns_none_when_nothing_resolvable(self):
|
||||
# No dispatch record AND firmware swallowed the plate path. The route
|
||||
# uses this signal to invoke the 3MF-scan fallback.
|
||||
from backend.app.services.printer_manager import resolve_plate_id
|
||||
|
||||
state = self._make_state(
|
||||
gcode_file="MyModel.3mf",
|
||||
subtask_name="MyModel",
|
||||
)
|
||||
assert resolve_plate_id(state) is None
|
||||
|
||||
def test_dispatched_subtask_required_to_avoid_false_match(self):
|
||||
# dispatched_plate_id without dispatched_subtask is incomplete — we
|
||||
# can't validate it points at the current print, so we ignore it.
|
||||
from backend.app.services.printer_manager import resolve_plate_id
|
||||
|
||||
state = self._make_state(
|
||||
gcode_file="MyModel.3mf",
|
||||
subtask_name="MyModel",
|
||||
dispatched_plate_id=4,
|
||||
dispatched_subtask=None,
|
||||
)
|
||||
assert resolve_plate_id(state) is None
|
||||
|
||||
Reference in New Issue
Block a user