Browse Source

Name an unnamed print stage "Preparing" on the card

    New printers report stage numbers before Bambuddy learns their names,
    and the H2C still has several. Those reached the printer card verbatim,
    as "Unknown stage (72)" -- a number that means nothing to the person
    reading it, on the one line that otherwise says what the printer is
    doing.

    Every stage that has turned out to be unnamed so far has been part of
    the run-up to printing, so an unnamed one now reads as "Preparing".
    That is the same literal stage 74 already carries rather than a second
    spelling of the same idea, so a card cannot show two different words
    for the same situation depending on which number the firmware picked.

    Display only, and deliberately not pushed down into get_stage_name.
    That function also feeds the stage-transition log line and the
    once-per-session warning added to capture unnamed stages so they can be
    named in a later release; there the number is the entire diagnostic
    value, and replacing it with "Preparing" would hide the only thing that
    reports these. Both paths are pinned by tests asserting they disagree
    for an unnamed stage and agree for a named one, so a later tidy-up
    cannot quietly collapse them.

    The idle sentinels are untouched: 255 on A1/P1 and -1 on X1 mean "no
    stage", not an unnamed one, and still resolve to nothing rather than
    being swept up by the fallback.

    Nothing keys logic off stg_cur_name -- it is display-only in the printer
    card, the print dialog's printer selector and the stream overlay -- so
    all three improve and none change behaviour. No i18n either way: the
    whole stage table has always been English.
MartinNYHC 1 week ago
parent
commit
26a827ea94

+ 24 - 1
backend/app/services/printer_manager.py

@@ -8,7 +8,13 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.printer import Printer
-from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name
+from backend.app.services.bambu_mqtt import (
+    STAGE_NAMES,
+    BambuMQTTClient,
+    MQTTLogEntry,
+    PrinterState,
+    get_stage_name,
+)
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 
 logger = logging.getLogger(__name__)
@@ -1170,6 +1176,23 @@ def get_derived_status_name(state: PrinterState, model: str | None = None) -> st
     # X1 models use -1 for idle, A1/P1 models use 255 for idle
     # Valid stage numbers are 0-254
     if 0 <= state.stg_cur < 255:
+        # A stage number the table does not cover is named "Preparing" rather
+        # than "Unknown stage (72)". New models report stages before Bambuddy
+        # learns their names -- the H2C still has several -- and the card is
+        # the wrong place to say so: the number means nothing to the person
+        # reading it, and every stage that has ever turned out to be unnamed
+        # was part of the run-up to printing, so "Preparing" is both the more
+        # useful answer and the more likely one.
+        #
+        # This is display only, and deliberately not pushed down into
+        # `get_stage_name`. That function also feeds the stage-transition log
+        # line and the once-per-session warning that exists precisely to
+        # capture unnamed stages so they can be named later (bambu_mqtt.py
+        # ~4100) -- there the number is the entire diagnostic value, and
+        # replacing it with "Preparing" would hide the very thing that
+        # reports these.
+        if state.stg_cur not in STAGE_NAMES:
+            return "Preparing"
         return get_stage_name(state.stg_cur)
 
     # If not in RUNNING state, no derived status needed

+ 91 - 0
backend/tests/unit/services/test_unknown_stage_reads_as_preparing.py

@@ -0,0 +1,91 @@
+"""A stage number Bambuddy cannot name reads as "Preparing" on the card.
+
+New printers report stages before Bambuddy learns their names -- the H2C still
+has several -- and until now those reached the card as ``Unknown stage (72)``.
+The number means nothing to the person reading it, and the card is not where it
+belongs: every stage that has turned out to be unnamed so far was part of the
+run-up to printing, so "Preparing" is both the more useful answer and the more
+likely one.
+
+The substitution is display-only. ``get_stage_name`` still reports the number,
+because it also feeds the stage-transition log line and the once-per-session
+warning that exists precisely to capture unnamed stages so they can be named in
+a later release. Replacing the number there would hide the only thing that
+reports these -- these tests pin that separation, not just the new label.
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import STAGE_NAMES, get_stage_name
+from backend.app.services.printer_manager import get_derived_status_name
+
+pytestmark = pytest.mark.unit
+
+
+class _State:
+    """Minimal PrinterState stand-in: only the fields this path reads."""
+
+    def __init__(self, stg_cur: int, state: str = "RUNNING") -> None:
+        self.stg_cur = stg_cur
+        self.state = state
+        self.temperatures: dict = {}
+        self.progress = 0
+        self.layer_num = 0
+
+
+# Two numbers no firmware in the table uses. 72 is the one reported from an H2C
+# in the field (#2916's logging change was added for exactly this gap).
+UNNAMED = [n for n in (68, 70, 71, 72, 73, 75, 76, 80, 200, 254) if n not in STAGE_NAMES]
+
+
+class TestTheCard:
+    @pytest.mark.parametrize("stage", UNNAMED)
+    def test_an_unnamed_stage_reads_as_preparing(self, stage):
+        assert get_derived_status_name(_State(stage)) == "Preparing"
+
+    def test_it_never_shows_the_raw_number(self):
+        for stage in UNNAMED:
+            assert "Unknown" not in (get_derived_status_name(_State(stage)) or "")
+
+    @pytest.mark.parametrize("stage", sorted(STAGE_NAMES))
+    def test_every_named_stage_keeps_its_own_name(self, stage):
+        # The substitution must not swallow the table it is standing in for.
+        assert get_derived_status_name(_State(stage)) == STAGE_NAMES[stage]
+
+    def test_the_label_matches_the_one_the_table_already_uses(self):
+        # Stage 74 is "Preparing" in STAGE_NAMES. An unnamed stage renders the
+        # same string rather than a second spelling of the same idea.
+        assert get_derived_status_name(_State(74)) == get_derived_status_name(_State(UNNAMED[0]))
+
+    def test_a_paused_printer_is_covered_too(self):
+        assert get_derived_status_name(_State(UNNAMED[0], state="PAUSE")) == "Preparing"
+
+
+class TestTheIdleSentinelsAreUntouched:
+    """255 and -1 are "no stage", not unnamed stages, and must stay None."""
+
+    def test_a1_p1_idle_sentinel(self):
+        assert get_derived_status_name(_State(255, state="IDLE")) is None
+
+    def test_x1_idle_sentinel(self):
+        assert get_derived_status_name(_State(-1, state="IDLE")) is None
+
+    def test_the_sentinels_stay_none_even_while_running(self):
+        # Out of range on both sides of 0..254; the temperature fallback below
+        # has nothing to work with here, so the answer is still no stage.
+        assert get_derived_status_name(_State(255)) is None
+
+
+class TestTheDiagnosticPathStillReportsTheNumber:
+    @pytest.mark.parametrize("stage", UNNAMED)
+    def test_get_stage_name_still_names_the_number(self, stage):
+        # This is what the log line and the unnamed-stage warning print. If it
+        # ever starts saying "Preparing", nobody can tell which stage to add.
+        assert get_stage_name(stage) == f"Unknown stage ({stage})"
+
+    def test_the_two_paths_genuinely_disagree_for_an_unnamed_stage(self):
+        stage = UNNAMED[0]
+        assert get_derived_status_name(_State(stage)) != get_stage_name(stage)
+
+    def test_and_agree_for_a_named_one(self):
+        assert get_derived_status_name(_State(74)) == get_stage_name(74)