diff --git a/backend/app/main.py b/backend/app/main.py index ac1d900b4..d26c4f7c5 100644 --- a/backend/app/main.py +++ b/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 diff --git a/backend/tests/unit/test_printer_kill_switch.py b/backend/tests/unit/test_printer_kill_switch.py index 8ae436104..e5dd94e9d 100644 --- a/backend/tests/unit/test_printer_kill_switch.py +++ b/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={}, diff --git a/backend/tests/unit/test_printer_offline_notification.py b/backend/tests/unit/test_printer_offline_notification.py index 4756f697f..a48dc456f 100644 --- a/backend/tests/unit/test_printer_offline_notification.py +++ b/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={}, diff --git a/backend/tests/unit/test_progress_milestones_3211.py b/backend/tests/unit/test_progress_milestones_3211.py new file mode 100644 index 000000000..f0490b043 --- /dev/null +++ b/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]