mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Count progress milestones from the first layer (#3211)
This commit is contained in:
+54
-14
@@ -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]
|
||||
Reference in New Issue
Block a user