Browse Source

Count progress milestones from the first layer (#3211)

maziggy 1 day ago
parent
commit
995b4ada59

+ 1 - 0
CHANGELOG.md

@@ -59,6 +59,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Progress milestones no longer send 75% at the start of a print, and 25% and 50% arrive again (#3211, reported by @BurgerKerman)** — An A1 mini reports a progress of 85% in the first moment of a print, while it is still heating the bed, and then values such as 3%, 40% and 44% through its calibration, before the first layer starts at 45%. Bambuddy read that 85% as progress and sent the 75% milestone together with Print Started, with an unknown remaining time. With 75% then on record, the 25% and 50% milestones of that print never fired. Milestones now count only from the first printed layer, and every new print starts them from zero. A printer that started layer 1 already past 25% sends the 25% milestone then. Until a print's layer count is known, the printer's preparation stage (bed preheating, homing, calibration) holds milestones back the same way. Printers that report neither behave as before.
 - **A printer that refuses uploads no longer empties the print queue (#3210, reported by @Leander-Vh)** — When a printer's file service would not take a file, the queue marked the job failed. That left the printer idle, so the next pass gave it the next job, which failed the same way: one P2S whose file service answered `421 There are too many connections` failed 43 queued jobs in ten minutes. When the file never reached the printer (the connection was refused, timed out or dropped), the job now stays in the queue, and the printer gets no new jobs for five minutes. The wait doubles with each refusal in a row, up to an hour, and the first upload that gets through resets it. Jobs for any printer of that model go to the others, and a job assigned to that printer shows "*printer* is not accepting files — Bambuddy will retry automatically" until it can be sent. One Job Waiting notification goes out per outage, not one per retry, and **Keep bed warm between prints** does not heat a printer that is waiting this way. A rejected access code, a full SD card (including an FTP 452 reply, which used to be read as a network error) or an upload too slow to finish still fails the job, because a retry would fail the same way.
 - **Re-reading a slot's RFID says when the printer refuses, and older firmware gets the command it understands (#3206, reported by @Sawtaytoes)** — The refresh sent `ams_get_rfid` and reported success without listening for the answer, so an X1C on X1Plus (base firmware 01.08.02.00), which answers it with `FAIL` / `ERROR STATE`, showed "Refreshing" while nothing happened, and the K profile was re-applied to a slot that had not been read. Bambuddy now waits for the printer's answer. When `ams_get_rfid` is refused by an X1, P1 or A1 printer that is not printing, it sends `M620 R<tray>`, the command Bambu Studio uses for those models; printers that accept `ams_get_rfid` never see it. If the printer refuses both, the refresh shows an error with the printer's reason and nothing is re-applied. A printer that does not answer at all is still treated as having taken the request, as before.
 - **The `{finish_photo_url}` link in a notification opens when authentication is on** — With authentication on, the link pointed at the archive's photo page, which needs a login that a link tapped in Telegram, CallMeBot or a Home Assistant notification can't carry, so it only ever showed an error. With authentication on the link now points at a copy of the photo under a long random name, which opens that one photo and nothing else and stops working after 3 days. With authentication off the link is unchanged. Photos attached to the message itself were not affected.

+ 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]