feat(printers): show plate name on card for multi-plate active prints (#881)

When two printers were running different plates of the same multi-plate
  3MF, the Printers page cards displayed the same file name on both and
  there was no way to tell them apart. The Queue view already had this
  information by cross-referencing the archive's plate list; the card
  didn't have the linkage.

  Expose `current_archive_id` (resolved by matching the MQTT `subtask_id`
  against `PrintArchive.subtask_id` — the bridge introduced in #972 for
  restart-resume) and `current_plate_id` (parsed from `gcode_file` by a
  new shared `parse_plate_id` helper) on the status endpoint. The helper
  is also called from the WebSocket push path so plate transitions
  reflect within 100 ms instead of waiting 30 s for the next REST poll;
  the archive id itself stays REST-only since it's stable for the life
  of a print and shouldn't make the push path touch the DB.

  The card fetches plate metadata via the same `api.getArchivePlates()`
  call QueuePage uses — shared React Query cache keeps it cheap across
  polls — and renders the actual plate name (or a "Plate N" fallback)
  only when `is_multi_plate` is true. Single-plate prints stay clean.
  Falls back to the previous `plate_N.gcode` regex path when there's no
  archive linkage (e.g. prints started directly from the printer LCD).

  Tests cover the plate-id extraction across Bambu Studio path shapes
  (backend parse_plate_id, printer_state_to_dict wiring) and the label
  override precedence in formatPrintName (frontend).
This commit is contained in:
maziggy
2026-04-21 09:46:42 +02:00
parent 07ef042729
commit fa1c46d9a5
10 changed files with 327 additions and 136 deletions
@@ -13,6 +13,7 @@ from backend.app.services.printer_manager import (
get_derived_status_name,
has_stg_cur_idle_bug,
init_printer_connections,
parse_plate_id,
printer_state_to_dict,
supports_chamber_temp,
supports_drying,
@@ -834,6 +835,22 @@ class TestPrinterStateToDict:
assert result["cover_url"] == "/api/v1/printers/1/cover"
def test_current_plate_id_extracted_from_gcode_file(self, mock_state):
"""Verify current_plate_id is parsed from a Bambu plate path (#881)."""
mock_state.gcode_file = "/Metadata/plate_3.gcode"
result = printer_state_to_dict(mock_state)
assert result["current_plate_id"] == 3
def test_current_plate_id_none_when_no_plate_segment(self, mock_state):
"""Verify current_plate_id stays None when gcode_file has no plate marker."""
mock_state.gcode_file = "/sdcard/test.gcode"
result = printer_state_to_dict(mock_state)
assert result["current_plate_id"] is None
def test_cover_url_none_when_not_running(self, mock_state):
"""Verify cover_url is None when not printing."""
mock_state.state = "IDLE"
@@ -1295,3 +1312,44 @@ class TestAmsChangeCallback:
# This tests the callback signature
assert manager._on_ams_change is not None
assert callable(manager._on_ams_change)
class TestParsePlateId:
"""Tests for parse_plate_id() — active-print plate extraction from gcode paths.
Regression coverage for #881 follow-up: the REST /status endpoint and the
WebSocket push path both use this helper, so they must agree on the plate
number the frontend sees.
"""
def test_bambu_metadata_path(self):
# Canonical path that Bambu Studio / OrcaSlicer stamp into the 3MF.
assert parse_plate_id("/Metadata/plate_2.gcode") == 2
def test_plate_one(self):
assert parse_plate_id("/Metadata/plate_1.gcode") == 1
def test_double_digit_plate(self):
assert parse_plate_id("/Metadata/plate_12.gcode") == 12
def test_none_input(self):
assert parse_plate_id(None) is None
def test_empty_string(self):
assert parse_plate_id("") is None
def test_path_without_plate_segment(self):
# Some firmware / slicers report a bare filename without the plate marker.
assert parse_plate_id("/upload/my-model.gcode") is None
def test_similar_but_non_matching_names(self):
# "plate.gcode" (no number) and "nameplate_2.gcode" (substring) must not
# be mistaken for real plate markers. The regex anchors on `plate_<num>`.
assert parse_plate_id("/Metadata/plate.gcode") is None
assert parse_plate_id("/plates/3.gcode") is None
def test_substring_match_still_extracts(self):
# The regex isn't anchored to the start of a segment — any occurrence
# 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