Count progress milestones from the first layer (#3211)

This commit is contained in:
maziggy
2026-10-01 13:05:46 +02:00
4 changed files with 181 additions and 14 deletions
+54 -14
View File
@@ -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
@@ -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={},
@@ -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={},
@@ -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]