Browse Source

Queue: a print the printer resumes finishes its job (#3485)

- A printer that picks a print back up (A1 mini after power-loss
  recovery, or a print Bambuddy reconnects to after a restart) reports
  the subtask as the file name with ".3mf"; the completion guard
  stripped extensions from the archive side only, so the completion
  was ignored and the queue item stayed "printing", blocking the queue
- New _dispatched_name_matches_echo() tries the echo as it came and
  with its extensions stripped; used by the completion guard and the
  external-print outcome check, which had the same gap
- Tests for the resumed-echo names from the report, a model named
  "Foo.3mf", and a different file still being rejected
maziggy 1 day ago
parent
commit
b446b9102b
3 changed files with 75 additions and 3 deletions
  1. 1 0
      CHANGELOG.md
  2. 17 2
      backend/app/main.py
  3. 57 1
      backend/tests/unit/test_completion_queue_item_match.py

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 
 ### Fixed
 ### Fixed
+- **A queue job resumed by the printer finishes in the queue (#3485, reported by @SaadShaya7)** — Bambuddy checks that a print's completion is about the queue job it closes, by comparing the name the printer reports with the name the job was sent under. A printer that picks a print back up, such as an A1 mini resuming after a power cut or a print Bambuddy reconnects to after a restart, reports the file name with ".3mf" on the end instead, so the names no longer matched: the completion was ignored and the job stayed "printing", blocking the printer's queue until it was stopped by hand. The name is now also compared without its extension. The same comparison decides whether a print counts as started outside Bambuddy, so with **Also ask for prints started outside Bambuddy** on, such a resumed queue job is no longer asked about as an external print.
 - **A virtual H2C reports the right model to the slicer** — The H2C comes as two models: older units (O1C, serials starting 094, like the H2D) and those from 2026 (O1C2, serials starting 31B8B). Bambu Studio only syncs printer information with a virtual printer that reports the printer's own one, and says "Printer not connected" otherwise, but the virtual printer list showed both as "H2C". The list now names each H2C entry with its serial family, and says to pick the one whose serial starts like the printer's. A proxy virtual printer takes the right one from its target printer's serial, and a virtual O1C2 now has a serial starting 31B8B like a real one, instead of the H2D's 094, so the slicer also shows it the H2C's HMS texts. An existing virtual O1C2 gets a new serial with this: add it in the slicer again once.
 - **A virtual H2C reports the right model to the slicer** — The H2C comes as two models: older units (O1C, serials starting 094, like the H2D) and those from 2026 (O1C2, serials starting 31B8B). Bambu Studio only syncs printer information with a virtual printer that reports the printer's own one, and says "Printer not connected" otherwise, but the virtual printer list showed both as "H2C". The list now names each H2C entry with its serial family, and says to pick the one whose serial starts like the printer's. A proxy virtual printer takes the right one from its target printer's serial, and a virtual O1C2 now has a serial starting 31B8B like a real one, instead of the H2D's 094, so the slicer also shows it the H2C's HMS texts. An existing virtual O1C2 gets a new serial with this: add it in the slicer again once.
 - **A slicer whose clock is a little behind can connect to a virtual printer again** — A virtual printer makes a new certificate every time Bambuddy starts, and it was valid only from that second on. Bambu Studio on a computer whose clock ran even a minute behind saw a certificate that was not valid yet, refused it, and reported only "-1" when connecting with the access code, which looks exactly like a missing certificate. New certificates now start a day back (a printer certificate never before its CA), so a restart no longer locks out such a computer. Nothing needs to be imported again.
 - **A slicer whose clock is a little behind can connect to a virtual printer again** — A virtual printer makes a new certificate every time Bambuddy starts, and it was valid only from that second on. Bambu Studio on a computer whose clock ran even a minute behind saw a certificate that was not valid yet, refused it, and reported only "-1" when connecting with the access code, which looks exactly like a missing certificate. New certificates now start a day back (a printer certificate never before its CA), so a restart no longer locks out such a computer. Nothing needs to be imported again.
 - **A printer prompt answered on the printer leaves the HMS list (#3487)** — Some faults, such as the A1 mini's "is the filament extruded?" prompt while loading filament, come in the printer's print_error field rather than its HMS list. Bambuddy showed them with the HMS errors but did not notice when the printer set that field back to 0, so the entry stayed on the printer card until "Clear errors" was clicked. It now goes as soon as the printer clears it, and a newer code replaces the old one. An HMS list update that arrives while such a fault is still active no longer drops it either.
 - **A printer prompt answered on the printer leaves the HMS list (#3487)** — Some faults, such as the A1 mini's "is the filament extruded?" prompt while loading filament, come in the printer's print_error field rather than its HMS list. Bambuddy showed them with the HMS errors but did not notice when the printer set that field back to 0, so the entry stayed on the printer card until "Clear errors" was clicked. It now goes as soon as the printer clears it, and a newer code replaces the old one. An HMS list update that arrives while such a fault is still active no longer drops it either.

+ 17 - 2
backend/app/main.py

@@ -4637,7 +4637,7 @@ async def _decide_outcome_for_external_print(db, printer_id: int, observed_name:
 
 
     expected = await _queue_item_dispatched_name(db, dispatched_here)
     expected = await _queue_item_dispatched_name(db, dispatched_here)
     observed = (observed_name or "").strip()
     observed = (observed_name or "").strip()
-    if not expected or not observed or _subtask_names_match(expected, observed):
+    if not expected or not observed or _dispatched_name_matches_echo(expected, observed):
         logger.info(
         logger.info(
             "[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so "
             "[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so "
             "Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.",
             "Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.",
@@ -8949,6 +8949,21 @@ def _subtask_names_match(expected: str, observed: str) -> bool:
     return False
     return False
 
 
 
 
+def _dispatched_name_matches_echo(expected: str, observed: str) -> bool:
+    """Whether the printer's subtask echo is the name a print was dispatched as.
+
+    A dispatched print is echoed under its bare name at start, but a printer
+    that picks a print back up -- Bambuddy reconnecting to a print already
+    running, or an A1 mini resuming after power-loss recovery -- can report the
+    file name with its extension instead (#3485). The echo is tried both as it
+    came and with the extensions stripped; the raw form still has to count, so
+    a model named ``Foo.3mf`` (file ``Foo.3mf.gcode.3mf``) keeps matching.
+    """
+    return _subtask_names_match(expected, observed) or _subtask_names_match(
+        expected, _subtask_name_from_filename(observed)
+    )
+
+
 async def _queue_item_dispatched_name(db, item) -> str:
 async def _queue_item_dispatched_name(db, item) -> str:
     """The subtask name *item* was dispatched under, or "" when unknowable.
     """The subtask name *item* was dispatched under, or "" when unknowable.
 
 
@@ -8993,7 +9008,7 @@ async def _completion_belongs_to_queue_item(db, item, data: dict) -> bool:
         return True
         return True
 
 
     expected = _subtask_name_from_filename(archive.filename)
     expected = _subtask_name_from_filename(archive.filename)
-    if not expected or _subtask_names_match(expected, observed):
+    if not expected or _dispatched_name_matches_echo(expected, observed):
         return True
         return True
 
 
     logging.getLogger(__name__).warning(
     logging.getLogger(__name__).warning(

+ 57 - 1
backend/tests/unit/test_completion_queue_item_match.py

@@ -14,10 +14,15 @@ the unverifiable cases through rather than strand an item in ``printing``.
 
 
 import pytest
 import pytest
 
 
-from backend.app.main import _completion_belongs_to_queue_item, _subtask_name_from_filename
+from backend.app.main import (
+    _ask_outcome_for_external_print,
+    _completion_belongs_to_queue_item,
+    _subtask_name_from_filename,
+)
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
 
 
 
 
 class TestSubtaskNameFromFilename:
 class TestSubtaskNameFromFilename:
@@ -89,6 +94,37 @@ class TestCompletionBelongsToQueueItem:
 
 
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "ams_rack"}) is True
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "ams_rack"}) is True
 
 
+    @pytest.mark.parametrize(
+        ("archive_filename", "subtask"),
+        [
+            # A1 mini after power-loss recovery: dispatched as the bare name,
+            # resumed and completed under the file name (#3485).
+            (
+                "Bottom-heavy; stands upright without saucer.3mf",
+                "Bottom-heavy;_stands_upright_without_saucer.3mf",
+            ),
+            ("mini-bench_plate_1.3mf", "mini-bench_plate_1.3mf"),
+            ("new bench.gcode.3mf", "new_bench.3mf"),
+            ("new bench.gcode.3mf", "new_bench.gcode.3mf"),
+        ],
+    )
+    async def test_accepts_its_own_print_echoed_with_the_extension(self, db_session, archive_filename, subtask):
+        item = await _seed(db_session, archive_filename=archive_filename)
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": subtask}) is True
+
+    async def test_a_model_named_like_a_file_still_matches(self, db_session):
+        # "Foo.3mf" is the model's own name here; stripping the echo to "Foo"
+        # must not be the only form tried.
+        item = await _seed(db_session, archive_filename="Foo.3mf.gcode.3mf")
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Foo.3mf"}) is True
+
+    async def test_an_extension_does_not_make_another_print_match(self, db_session):
+        item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Test.3mf"}) is False
+
     @pytest.mark.parametrize("subtask", [None, "", "   "])
     @pytest.mark.parametrize("subtask", [None, "", "   "])
     async def test_lets_an_unidentified_completion_through(self, db_session, subtask):
     async def test_lets_an_unidentified_completion_through(self, db_session, subtask):
         # No subtask name to compare means unverifiable, not wrong. Refusing here
         # No subtask name to compare means unverifiable, not wrong. Refusing here
@@ -119,6 +155,26 @@ class TestCompletionBelongsToQueueItem:
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
 
 
 
 
+@pytest.mark.asyncio
+class TestOutcomePromptForAResumedQueuePrint:
+    """The outcome prompt holds a printing queue row against the observed name
+    with the same rule, so a resumed queue print must not read as external."""
+
+    async def test_a_resumed_queue_print_is_not_asked_about(self, db_session):
+        item = await _seed(db_session, archive_filename="mini-bench_plate_1.3mf")
+        db_session.add(Settings(key="confirm_outcome_external_prints", value="true"))
+        await db_session.flush()
+
+        assert await _ask_outcome_for_external_print(db_session, item.printer_id, "mini-bench_plate_1.3mf") is False
+
+    async def test_another_file_is_still_asked_about(self, db_session):
+        item = await _seed(db_session, archive_filename="mini-bench_plate_1.3mf")
+        db_session.add(Settings(key="confirm_outcome_external_prints", value="true"))
+        await db_session.flush()
+
+        assert await _ask_outcome_for_external_print(db_session, item.printer_id, "Other.3mf") is True
+
+
 @pytest.mark.sqlite_only  # on PostgreSQL the guard checks the throwaway server's databases instead
 @pytest.mark.sqlite_only  # on PostgreSQL the guard checks the throwaway server's databases instead
 class TestDisposableDatabaseGuard:
 class TestDisposableDatabaseGuard:
     """The suite must never be able to open a session against a real database.
     """The suite must never be able to open a session against a real database.