mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +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:
@@ -8,6 +8,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
|
||||
|
||||
### Fixed
|
||||
- **Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF** ([#1166](https://github.com/maziggy/bambuddy/issues/1166), reported by @smandon) — On printers running firmware that drops the plate path from `print.gcode_file` (the reporter's case: P1S 01.10.00.00, but the same shape appears on other firmware revisions), the printer reports `gcode_file: MyModel.3mf` instead of `gcode_file: /Metadata/plate_4.gcode`. The `/printers/{id}/cover` route's regex (`plate_(\d+)\.gcode`) found nothing in the bare `.3mf` filename, defaulted to plate 1, and the printer card showed `Metadata/plate_1.png` from the 3MF — even though the user dispatched plate 4. Same problem hit `current_plate_id` on the status response (printer card detail row showed plate 1). Two-pronged fix on a precedence ladder: **(1) Bambuddy now records the plate it dispatched** — `start_print()` writes `(dispatched_plate_id, dispatched_subtask)` onto `PrinterState` at publish time, and a new `resolve_plate_id(state)` helper prefers that record over the gcode_file regex when `dispatched_subtask == state.subtask_name` (the subtask check rejects stale entries from a prior Bambuddy-dispatched print bleeding into a Studio-direct dispatch). **(2) After the 3MF lands on disk, the cover route scans the zip for a unique `Metadata/plate_*.gcode` entry**: per-plate archives sliced separately in Bambu Studio bundle thumbnails for *every* plate but only the *active* plate's gcode, so a single match unambiguously identifies the plate even when no Bambuddy dispatch exists (Studio-direct flow). Final fallback is plate 1, unchanged. The cover-byte cache key was also simplified — `plate_num` was removed from the key now that resolution is late-bound; `clear_cover_cache()` already runs on every print start, so different plates of the same project always re-fetch a fresh thumbnail. Coverage: 5 unit tests in `test_printer_manager.py::TestResolvePlateId` (dispatch precedence, stale-subtask guard, gcode regex fallback, default-1 path, missing-subtask guard), 4 unit tests in `test_bambu_mqtt.py::TestStartPrintRecordsDispatchedPlate` (dispatch record set/cleared/overwritten/skipped on disconnect), 2 integration tests in `test_printers_api.py` (dispatch wins over plate-1 default; 3MF-scan fallback for per-plate archive without dispatch). Studio-direct multi-plate prints (no dispatch record AND multiple plate gcodes in the 3MF) still default to plate 1 — matches the firmware's own ambiguity, not regressed by this change.
|
||||
|
||||
- **AMS slot configuration intermittently fails to reach the printer after several configs in a row** ([#1164](https://github.com/maziggy/bambuddy/issues/1164), reported by @RosdasHH) — Configuring AMS slots a handful of times (the reporter saw it almost every 6th change) would silently stop reaching the printer; ~1 minute later the filament colours on the printer would briefly jump between slots, then settle. Root cause was the zombie-session watchdog at `bambu_mqtt.py:861` introduced for [#887](https://github.com/maziggy/bambuddy/issues/887). When an `ams_filament_setting` response took >10 s (normal under load — concurrent K-profile fetches, busy printer, network jitter) the watchdog incremented an `_ams_cmd_unanswered` counter and zeroed `_last_ams_cmd_time` so it wouldn't re-trigger on the next status push. The bug: the response handler that reset the counter was guarded by `and self._last_ams_cmd_time > 0` — so when the late response *did* arrive (after the watchdog had already zeroed the timer), the counter stayed armed at 1. The next slow response on any `ams_filament_setting` command — possibly minutes or hours later, on an entirely unrelated config attempt — would take the counter to 2 and trigger `force_reconnect_stale_session()`. The user-visible symptoms match exactly: configs stop landing (because MQTT reconnects mid-publish, dropping the in-flight command and surfacing as `Cannot set AMS filament setting: not connected` if the user retries during the ~1 min reconnect window), then the queued state finally lands when the reconnect completes (the "filament colours jumping around" the reporter described). Fix is to drop the `_last_ams_cmd_time > 0` guard: any `ams_filament_setting` response — late or not — proves the channel is alive, so the counter must reset. Watchdog still trips on a real zombie session (no responses at all for two consecutive >10 s windows). Regression test in `test_bambu_mqtt.py::TestZombieSessionDetection::test_late_response_after_watchdog_clears_counter_issue_1164` simulates the exact sequence (watchdog fires → late response arrives → second slow response on a fresh command) and asserts the counter resets to 0 on the late response and the second command doesn't tip the threshold to 2. Other 10 zombie-detection tests still pass unchanged.
|
||||
|
||||
## [0.2.4b1] - 2026-04-29
|
||||
|
||||
@@ -40,8 +40,8 @@ from backend.app.services.bambu_ftp import (
|
||||
)
|
||||
from backend.app.services.printer_manager import (
|
||||
get_derived_status_name,
|
||||
parse_plate_id,
|
||||
printer_manager,
|
||||
resolve_plate_id,
|
||||
supports_chamber_temp,
|
||||
supports_drying,
|
||||
)
|
||||
@@ -570,7 +570,7 @@ async def get_printer_status(
|
||||
current_archive_id: int | None = None
|
||||
current_plate_id: int | None = None
|
||||
if state.state in ("RUNNING", "PAUSE"):
|
||||
current_plate_id = parse_plate_id(state.gcode_file)
|
||||
current_plate_id = resolve_plate_id(state)
|
||||
if state.subtask_id:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
@@ -738,7 +738,9 @@ async def test_printer_connection(
|
||||
return result
|
||||
|
||||
|
||||
# Cache for cover images (printer_id -> {(subtask_name, plate_num, view) -> image_bytes})
|
||||
# Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
|
||||
# Cleared on every print start by main.py::on_print_start, so re-dispatches with
|
||||
# different plates always fetch a fresh thumbnail without needing plate in the key.
|
||||
_cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
|
||||
|
||||
|
||||
@@ -774,21 +776,28 @@ async def get_printer_cover(
|
||||
if not subtask_name:
|
||||
raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
|
||||
|
||||
# Extract plate number from gcode_file (e.g., "/data/Metadata/plate_12.gcode" -> 12)
|
||||
plate_num = 1
|
||||
gcode_file = state.gcode_file
|
||||
if gcode_file:
|
||||
match = re.search(r"plate_(\d+)\.gcode", gcode_file)
|
||||
if match:
|
||||
plate_num = int(match.group(1))
|
||||
logger.info("Detected plate number %s from gcode_file: %s", plate_num, gcode_file)
|
||||
# Resolve the active plate. Precedence (#1166):
|
||||
# 1. The plate Bambuddy dispatched (authoritative when we sent the print)
|
||||
# 2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that
|
||||
# reflects the full path, e.g. some X1C builds)
|
||||
# 3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers
|
||||
# per-plate archives sliced separately in Bambu Studio, where the
|
||||
# printer's gcode_file echo is just the .3mf filename)
|
||||
# 4. Fall back to plate 1
|
||||
# The 3MF-scan fallback runs later — after the file is on disk.
|
||||
plate_num = resolve_plate_id(state)
|
||||
if plate_num is not None:
|
||||
logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name)
|
||||
|
||||
# Normalize view parameter
|
||||
view_key = view or "default"
|
||||
|
||||
# Check cache - include plate_num in cache key for multi-plate projects
|
||||
# Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache()
|
||||
# runs on every print start, so a re-dispatch with a different plate gets
|
||||
# a fresh image regardless. Pre-#1166 the key included plate_num, but with
|
||||
# late plate resolution the cache check would always miss.
|
||||
if printer_id in _cover_cache:
|
||||
cache_key = (subtask_name, plate_num, view_key)
|
||||
cache_key = (subtask_name, view_key)
|
||||
if cache_key in _cover_cache[printer_id]:
|
||||
return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
|
||||
|
||||
@@ -907,6 +916,21 @@ async def get_printer_cover(
|
||||
raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
|
||||
|
||||
try:
|
||||
# 3MF-scan fallback for plate detection (#1166). Per-plate archives
|
||||
# sliced separately in Bambu Studio contain a single
|
||||
# Metadata/plate_N.gcode for the active plate, even though
|
||||
# thumbnails for all plates are bundled. Using that gcode's plate
|
||||
# number prevents falling back to plate_1.png.
|
||||
if plate_num is None:
|
||||
plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)]
|
||||
if len(plate_gcodes) == 1:
|
||||
match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0])
|
||||
if match:
|
||||
plate_num = int(match.group(1))
|
||||
logger.info("Cover: detected plate %s from 3MF contents", plate_num)
|
||||
if plate_num is None:
|
||||
plate_num = 1
|
||||
|
||||
# Try common thumbnail paths in 3MF files
|
||||
# Use plate_num to get the correct plate's thumbnail for multi-plate projects
|
||||
# Use top-down view if requested (better for skip objects modal)
|
||||
@@ -934,10 +958,9 @@ async def get_printer_cover(
|
||||
for thumb_path in thumbnail_paths:
|
||||
try:
|
||||
image_data = zf.read(thumb_path)
|
||||
# Cache the result - include plate_num in cache key
|
||||
if printer_id not in _cover_cache:
|
||||
_cover_cache[printer_id] = {}
|
||||
_cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
|
||||
_cover_cache[printer_id][(subtask_name, view_key)] = image_data
|
||||
return Response(content=image_data, media_type="image/png")
|
||||
except KeyError:
|
||||
continue
|
||||
@@ -948,7 +971,7 @@ async def get_printer_cover(
|
||||
image_data = zf.read(name)
|
||||
if printer_id not in _cover_cache:
|
||||
_cover_cache[printer_id] = {}
|
||||
_cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
|
||||
_cover_cache[printer_id][(subtask_name, view_key)] = image_data
|
||||
return Response(content=image_data, media_type="image/png")
|
||||
|
||||
raise HTTPException(404, "No thumbnail found in 3MF file")
|
||||
|
||||
@@ -25,6 +25,7 @@ from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.bambu_ftp import (
|
||||
cache_3mf_download,
|
||||
delete_file_async,
|
||||
get_ftp_retry_settings,
|
||||
upload_file_async,
|
||||
@@ -684,6 +685,12 @@ class BackgroundDispatchService:
|
||||
)
|
||||
raise RuntimeError("Failed to start print")
|
||||
|
||||
# Register the archive's local 3MF in the cover-cache so the
|
||||
# /cover endpoint can skip FTP — we already have the file on
|
||||
# disk, no need to refetch 36 MB from a printer whose FTP is
|
||||
# busy serving the active print (#1166 follow-up).
|
||||
cache_3mf_download(job.printer_id, remote_filename, file_path)
|
||||
|
||||
# Wait for the printer to actually pick up the command before
|
||||
# marking the dispatch job complete (#1042). MQTT-publish success
|
||||
# only proves the command queued locally; the printer can still
|
||||
@@ -884,6 +891,10 @@ class BackgroundDispatchService:
|
||||
await db.rollback()
|
||||
raise RuntimeError("Failed to start print")
|
||||
|
||||
# Same as the archive path: register the library file's local
|
||||
# 3MF in the cover-cache so /cover skips FTP (#1166 follow-up).
|
||||
cache_3mf_download(job.printer_id, remote_filename, file_path)
|
||||
|
||||
# See _run_reprint_archive for rationale (#1042). On timeout
|
||||
# also rolls back the freshly-created archive so the library
|
||||
# flow doesn't leave behind a phantom row for a print that
|
||||
|
||||
@@ -193,6 +193,17 @@ class PrinterState:
|
||||
# Filament Track Switch (FTS) accessory — when installed, AMS info reports
|
||||
# bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
|
||||
fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
|
||||
# Plate dispatched by Bambuddy for the current print. Some firmware versions
|
||||
# (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
|
||||
# regex used to derive the plate number from the path always falls back to
|
||||
# plate 1 — and the printer card shows the wrong thumbnail (#1166). When
|
||||
# Bambuddy dispatches the print itself we know the plate authoritatively;
|
||||
# we record it here and prefer it over the gcode_file regex. The subtask
|
||||
# field guards against staleness: if the printer is currently running a
|
||||
# different subtask (e.g. a Studio-direct dispatch), these values are
|
||||
# ignored. Cleared on disconnect.
|
||||
dispatched_plate_id: int | None = None
|
||||
dispatched_subtask: str | None = None
|
||||
# H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
|
||||
# snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
|
||||
h2d_extruder_snow: dict = field(default_factory=dict)
|
||||
@@ -3226,6 +3237,13 @@ class BambuMQTTClient:
|
||||
|
||||
logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
# Record what we dispatched so /cover can pick the right plate
|
||||
# thumbnail even when the printer's gcode_file echo is just the
|
||||
# 3MF filename without a plate path (#1166). Match the same
|
||||
# subtask_name shape we send so the comparison in the cover route
|
||||
# works against state.subtask_name reflected back via MQTT.
|
||||
self.state.dispatched_plate_id = plate_id
|
||||
self.state.dispatched_subtask = command["print"]["subtask_name"]
|
||||
return True
|
||||
else:
|
||||
# Log why we couldn't send the command
|
||||
|
||||
@@ -20,7 +20,13 @@ from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
|
||||
from backend.app.services.bambu_ftp import (
|
||||
cache_3mf_download,
|
||||
delete_file_async,
|
||||
get_ftp_retry_settings,
|
||||
upload_file_async,
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import printer_manager, supports_drying
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
@@ -1964,6 +1970,12 @@ class PrintScheduler:
|
||||
if started:
|
||||
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
|
||||
|
||||
# Register the local 3MF in the cover-cache so /cover skips FTP
|
||||
# (#1166 follow-up). file_path was resolved earlier from either the
|
||||
# archive or the library file row.
|
||||
if file_path is not None:
|
||||
cache_3mf_download(item.printer_id, remote_filename, file_path)
|
||||
|
||||
# Hold the printer against further dispatches until the watchdog
|
||||
# confirms the printer transitioned (or until the hard timeout).
|
||||
# Prevents multi-plate batches from triple-dispatching onto the
|
||||
|
||||
@@ -688,6 +688,29 @@ def parse_plate_id(gcode_file: str | None) -> int | None:
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def resolve_plate_id(state) -> int | None:
|
||||
"""Resolve the active plate number from a PrinterState.
|
||||
|
||||
Some firmware versions (e.g. P1S 01.10.00.00, #1166) put only the .3mf
|
||||
filename in print.gcode_file, so parse_plate_id() returns None and the
|
||||
printer card falls back to plate 1 — wrong thumbnail. When Bambuddy
|
||||
dispatched the print itself we already know the right plate, so we prefer
|
||||
that over the gcode_file echo. The subtask check prevents stale values
|
||||
from a previous Bambuddy-dispatched print bleeding into a Studio-direct
|
||||
print on the same printer.
|
||||
"""
|
||||
dispatched_plate = getattr(state, "dispatched_plate_id", None)
|
||||
dispatched_subtask = getattr(state, "dispatched_subtask", None)
|
||||
if (
|
||||
dispatched_plate is not None
|
||||
and dispatched_subtask is not None
|
||||
and state.subtask_name
|
||||
and dispatched_subtask == state.subtask_name
|
||||
):
|
||||
return dispatched_plate
|
||||
return parse_plate_id(state.gcode_file)
|
||||
|
||||
|
||||
def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
|
||||
"""Convert PrinterState to a JSON-serializable dict.
|
||||
|
||||
@@ -909,7 +932,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
|
||||
# a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).
|
||||
# current_archive_id is intentionally REST-only — it's stable for the life
|
||||
# of a print and needs a DB lookup the WebSocket path shouldn't pay for.
|
||||
"current_plate_id": parse_plate_id(state.gcode_file),
|
||||
"current_plate_id": resolve_plate_id(state),
|
||||
# Plate-clear gate (#939). Lives on the PrinterManager rather than PrinterState,
|
||||
# so surface it here — without this, WebSocket merges drop the flag and the
|
||||
# "Clear Plate" button only appears when the 30 s REST fallback poll runs.
|
||||
|
||||
@@ -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