Browse Source

Count progress milestones from the first layer (#3211)

maziggy 1 day ago
parent
commit
91a04ae121

+ 54 - 14
backend/app/main.py

@@ -1507,6 +1507,52 @@ def _take_new_hms_faults(printer_id: int, errors: list) -> list:
     return _hms_errors_to_notify(errors, new)
 
 
+# `stg_cur` values that mean the printer is not in a preparation stage: 0 is
+# "Printing", -1 and 255 are "no stage" (#3211).
+_NO_PREPARATION_STAGES = frozenset({0, -1, 255})
+
+
+def _progress_milestone_to_notify(printer_id: int, state: PrinterState) -> int | None:
+    """The milestone (25, 50 or 75) this status update reaches for the first time, if any.
+
+    Records it as notified. Called only for a printing state with progress above 0.
+
+    Progress before the first layer is not progress through the print (#3211).
+    An A1 mini reports ``mc_percent`` 85 in the first frame of a print, while
+    still preheating the bed at layer 0, then 3, 7, 40 and 44 through its
+    calibration, and starts layer 1 at 45. Read as progress, that 85 sent the
+    75% notification together with Print Started, and since 75 was then on
+    record, 25 and 50 could never fire. So nothing counts while the printer
+    reports a layer count but has not started layer 1.
+
+    Without a layer count -- a print whose first frame carried no
+    ``total_layer_num``, until the pushall that asks for it is answered
+    (#2702) -- the preparation stage says the same thing: before layer 1 the
+    A1 mini reported ``stg_cur`` 2, 4, 14 and 1 (bed preheating, calibration,
+    homing), and 0 from layer 1 on. -1 and 255 are "no stage". A printer that
+    reports neither keeps the old behaviour rather than never notifying.
+    """
+    if (state.layer_num or 0) < 1:
+        if (state.total_layers or 0) > 0:
+            return None
+        if state.stg_cur not in _NO_PREPARATION_STAGES:
+            return None
+
+    progress = state.progress or 0
+    current_milestone = 0
+    if progress >= 75:
+        current_milestone = 75
+    elif progress >= 50:
+        current_milestone = 50
+    elif progress >= 25:
+        current_milestone = 25
+
+    if current_milestone > _last_progress_milestone.get(printer_id, 0):
+        _last_progress_milestone[printer_id] = current_milestone
+        return current_milestone
+    return None
+
+
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     # Connected-edge reconciliation (#1542 follow-up). When the printer
@@ -1817,20 +1863,8 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     is_printing = state.state in ("RUNNING", "PRINTING")
 
     if is_printing and progress > 0:
-        # Determine which milestone we've reached
-        current_milestone = 0
-        if progress >= 75:
-            current_milestone = 75
-        elif progress >= 50:
-            current_milestone = 50
-        elif progress >= 25:
-            current_milestone = 25
-
-        last_milestone = _last_progress_milestone.get(printer_id, 0)
-
-        # If we've crossed a new milestone, send notification
-        if current_milestone > last_milestone:
-            _last_progress_milestone[printer_id] = current_milestone
+        current_milestone = _progress_milestone_to_notify(printer_id, state)
+        if current_milestone is not None:
             try:
                 from backend.app.models.printer import Printer
 
@@ -3947,6 +3981,12 @@ async def on_print_start(printer_id: int, data: dict):
 
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
+    # A new print starts its milestones from zero (#3211). The status path only
+    # resets on progress below 5 while not printing, which a printer that goes
+    # from FINISH at 100% straight into a new print at a preparation-phase 85%
+    # never shows. This callback does not fire after a Bambuddy restart (#1304)
+    # or on resume from pause, so it cannot repeat a milestone mid-print.
+    _last_progress_milestone[printer_id] = 0
     _kill_switch_notification_tasks.pop(printer_id, None)
 
     # #1721: drop any leftover pre-captured finish frame from a prior print

+ 5 - 0
backend/tests/unit/test_printer_kill_switch.py

@@ -75,6 +75,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         progress=0,
         remaining_time=0,
         layer_num=0,
+        total_layers=0,
         temperatures={},
         nozzles=[],
         raw_data={},
@@ -167,6 +168,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
         progress=0,
         remaining_time=0,
         layer_num=0,
+        total_layers=0,
         temperatures={},
         nozzles=[],
         raw_data={},
@@ -246,6 +248,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         progress=0,
         remaining_time=0,
         layer_num=0,
+        total_layers=0,
         temperatures={},
         nozzles=[],
         raw_data={},
@@ -275,6 +278,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         progress=0,
         remaining_time=0,
         layer_num=0,
+        total_layers=0,
         temperatures={},
         nozzles=[],
         raw_data={},
@@ -358,6 +362,7 @@ async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_
         progress=42,
         remaining_time=600,
         layer_num=50,
+        total_layers=100,
         temperatures={},
         nozzles=[],
         raw_data={},

+ 1 - 0
backend/tests/unit/test_printer_offline_notification.py

@@ -52,6 +52,7 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
         state=state,
         progress=0,
         layer_num=0,
+        total_layers=0,
         temperatures={},
         nozzles=[],
         raw_data={},

+ 121 - 0
backend/tests/unit/test_progress_milestones_3211.py

@@ -0,0 +1,121 @@
+"""Progress milestones count from the first layer, not from the start of preparation (#3211).
+
+An A1 mini (firmware 01.07.01.00) reports ``mc_percent`` 85 in the first frame
+of a print, while still preheating the bed at layer 0. Through its calibration
+the value then reads 3, 7, 40 and 44, and the first real layer starts at 45.
+Read as progress, the 85 sent the 75% notification together with Print
+Started -- with "Unknown" remaining time, since that frame reports 0 -- and
+with 75 then on record, 25 and 50 could never fire.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+
+pytestmark = pytest.mark.unit
+
+PRINTER = 1
+
+
+@pytest.fixture(autouse=True)
+def _clean_milestones():
+    main_module._last_progress_milestone.clear()
+    yield
+    main_module._last_progress_milestone.clear()
+
+
+def _frame(progress: float, layer_num: int, total_layers: int = 128, stg_cur: int = 0) -> SimpleNamespace:
+    return SimpleNamespace(progress=progress, layer_num=layer_num, total_layers=total_layers, stg_cur=stg_cur)
+
+
+def _run(frames) -> list[int]:
+    sent = []
+    for progress, layer, *rest in frames:
+        milestone = main_module._progress_milestone_to_notify(PRINTER, _frame(progress, layer, *rest))
+        if milestone is not None:
+            sent.append(milestone)
+    return sent
+
+
+# The reporter's print, frame by frame from the support bundle: preparation at
+# layer 0, then the print proper from layer 1.
+A1_MINI_PRINT = [
+    (85, 0),
+    (3, 0),
+    (7, 0),
+    (40, 0),
+    (44, 0),
+    (45, 1),
+    (50, 1),
+    (52, 2),
+    (75, 30),
+    (97, 120),
+    (100, 128),
+]
+
+
+def test_the_reported_print_sends_each_milestone_once_and_none_at_the_start():
+    assert _run(A1_MINI_PRINT) == [25, 50, 75]
+
+
+def test_nothing_is_sent_during_preparation():
+    assert _run(A1_MINI_PRINT[:5]) == []
+    assert main_module._last_progress_milestone.get(PRINTER, 0) == 0
+
+
+def test_the_first_layer_catches_up_to_the_milestone_already_passed():
+    """The print proper starts at 45 here, so 25 goes out at 45 -- once."""
+    assert _run([(85, 0), (45, 1), (46, 1)]) == [25]
+
+
+def test_a_printer_without_layer_data_keeps_the_old_behaviour():
+    """No layer count means nothing to gate on; never notifying would be worse."""
+    assert _run([(10, 0, 0), (30, 0, 0), (60, 0, 0), (80, 0, 0)]) == [25, 50, 75]
+
+
+def test_without_a_layer_count_the_preparation_stage_holds_it_back():
+    """A first frame with no total_layer_num leaves nothing to gate on by layers.
+
+    The A1 mini's preparation stages, from the bundle: 2 (bed preheating),
+    4, 14 and 1, then 0 from the first layer. Until the pushall brings the
+    total, the stage is what tells preparation from printing.
+    """
+    frames = [(85, 0, 0, 2), (3, 0, 0, 4), (40, 0, 0, 14), (44, 0, 0, 1), (45, 1, 128, 0), (50, 2, 128, 0)]
+    assert _run(frames) == [25, 50]
+
+
+@pytest.mark.parametrize("no_stage", [-1, 255])
+def test_no_stage_at_all_is_not_preparation(no_stage):
+    assert _run([(30, 0, 0, no_stage)]) == [25]
+
+
+def test_a_normal_print_is_unchanged():
+    frames = [(1, 1), (24, 10), (25, 11), (49, 20), (50, 21), (74, 30), (75, 31), (100, 40)]
+    assert _run(frames) == [25, 50, 75]
+
+
+@pytest.mark.asyncio
+async def test_a_new_print_starts_its_milestones_from_zero():
+    """A printer can go from FINISH at 100% straight into the next print.
+
+    The status path only resets on progress below 5 while not printing, which
+    that never shows, so the previous print's 75 would block every milestone
+    of the next one. The print-start callback resets it.
+    """
+    assert _run(A1_MINI_PRINT) == [25, 50, 75]
+
+    class _Stop(Exception):
+        pass
+
+    # The reset is one of the first things on_print_start does; the rest of it
+    # (archiving, usage tracking, notifications) is not under test here, so the
+    # call is stopped at the next statement.
+    stop_here = SimpleNamespace(pop=MagicMock(side_effect=_Stop))
+    with patch.object(main_module, "_kill_switch_notification_tasks", stop_here), pytest.raises(_Stop):
+        await main_module.on_print_start(PRINTER, {})
+
+    assert main_module._last_progress_milestone[PRINTER] == 0
+    assert _run(A1_MINI_PRINT) == [25, 50, 75]