Browse Source

MQTT: a print paused at startup resumes as the same print (#3485)

- a power cut that also restarts Bambuddy brings it back while the
  printer is paused in power-loss recovery; the first state is PAUSE,
  so the printer's PAUSE -> RUNNING was taken for a new print and got
  a second archive, left printing
- a print found paused at startup now resumes through the restart
  recovery a print found running already gets (#1304), so the
  original archive and queue item complete
- also covers a print paused by hand before a restart
maziggy 1 day ago
parent
commit
bae60ed5b4

+ 1 - 1
CHANGELOG.md

@@ -21,7 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### Fixed
 ### Fixed
 - **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset carries the settings changed on top of it: what was set in the slice dialog's process settings, the designer's settings that were kept ticked, and the source file's supports. The slicer wrote their values into the project but did not mark them as changed from the preset, so Bambu Studio opened the project with the preset's own values and a re-slice there lost them. They are now marked as changed. Slices with a cloud or local preset, and those that use the file's own settings, are unchanged.
 - **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset carries the settings changed on top of it: what was set in the slice dialog's process settings, the designer's settings that were kept ticked, and the source file's supports. The slicer wrote their values into the project but did not mark them as changed from the preset, so Bambu Studio opened the project with the preset's own values and a re-slice there lost them. They are now marked as changed. Slices with a cloud or local preset, and those that use the file's own settings, are unchanged.
 - **A restart no longer sends printer errors again, and their titles name the right part (#3486, reported by @SaadShaya7)** — Printers keep some faults in their error list long after they happened, without showing them on the screen, for example "There was an unfinished print job when the printer lost power" after a power cut. Bambuddy remembered which faults it had already notified only until it restarted, so every restart sent each of those faults again. The notified faults are now stored, so a restart only notifies faults that are new. The title of a printer error notification also named the wrong part for several kinds of fault: USB, network and file errors came as "Nozzle/Extruder Error", camera faults as "Motion Controller Error", and an A1 mini's AMS Lite runouts as "Chamber Error". They now read "System Error", "Camera Error" and "AMS Lite Error"; AMS-HT, AMS 3H, toolhead, tool changer and air filter faults are named too, and a fault of any other kind is titled "Printer Error".
 - **A restart no longer sends printer errors again, and their titles name the right part (#3486, reported by @SaadShaya7)** — Printers keep some faults in their error list long after they happened, without showing them on the screen, for example "There was an unfinished print job when the printer lost power" after a power cut. Bambuddy remembered which faults it had already notified only until it restarted, so every restart sent each of those faults again. The notified faults are now stored, so a restart only notifies faults that are new. The title of a printer error notification also named the wrong part for several kinds of fault: USB, network and file errors came as "Nozzle/Extruder Error", camera faults as "Motion Controller Error", and an A1 mini's AMS Lite runouts as "Chamber Error". They now read "System Error", "Camera Error" and "AMS Lite Error"; AMS-HT, AMS 3H, toolhead, tool changer and air filter faults are named too, and a fault of any other kind is titled "Printer Error".
-- **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 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. When a power cut also restarts Bambuddy, it comes back while the printer is still paused in power-loss recovery, and the resume was taken for a new print: a second archive of the same print, left "printing". A print found paused when Bambuddy starts now carries on as the same print once it resumes, as one found printing already did; this also covers a print paused by hand before a restart.
 - **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.

+ 11 - 0
backend/app/services/bambu_mqtt.py

@@ -1637,6 +1637,10 @@ class BambuMQTTClient:
         self._previous_gcode_state: str | None = None
         self._previous_gcode_state: str | None = None
         self._previous_gcode_file: str | None = None
         self._previous_gcode_file: str | None = None
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
+        # #3485: the first state after startup was PAUSE with a print loaded,
+        # e.g. a printer in power-loss recovery when Bambuddy came back. Its
+        # PAUSE -> RUNNING is that print resuming, not a new one.
+        self._paused_at_startup: bool = False
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
         # #1721: one-shot guard so the end-of-print stage-22 detector
         # #1721: one-shot guard so the end-of-print stage-22 detector
@@ -5998,13 +6002,20 @@ class BambuMQTTClient:
 
 
         # Detect print start (state changes TO RUNNING with a file)
         # Detect print start (state changes TO RUNNING with a file)
         current_file = self.state.gcode_file or self.state.current_print
         current_file = self.state.gcode_file or self.state.current_print
+        if self._previous_gcode_state is None and self.state.state == "PAUSE" and current_file:
+            self._paused_at_startup = True
         is_new_print = (
         is_new_print = (
             self.state.state == "RUNNING"
             self.state.state == "RUNNING"
             and self._previous_gcode_state is not None  # #1304: skip on first push after Bambuddy startup
             and self._previous_gcode_state is not None  # #1304: skip on first push after Bambuddy startup
             and self._previous_gcode_state != "RUNNING"
             and self._previous_gcode_state != "RUNNING"
             and current_file
             and current_file
             and not self._was_running  # Prevent duplicates when resuming from PAUSE
             and not self._was_running  # Prevent duplicates when resuming from PAUSE
+            # #3485: a print paused when Bambuddy started resumes like one it
+            # found running (#1304): restart recovery below, not a new print.
+            and not (self._paused_at_startup and self._previous_gcode_state == "PAUSE")
         )
         )
+        if self.state.state != "PAUSE":
+            self._paused_at_startup = False
         # Also detect if file changed while running (new print started)
         # Also detect if file changed while running (new print started)
         is_file_change = (
         is_file_change = (
             self.state.state == "RUNNING"
             self.state.state == "RUNNING"

+ 97 - 0
backend/tests/unit/services/test_resume_after_startup_pause_3485.py

@@ -0,0 +1,97 @@
+"""A print paused when Bambuddy starts resumes as the same print (#3485).
+
+A power cut takes down the printers and the Bambuddy host together. Bambuddy
+comes back while the printers sit paused in power-loss recovery, so the first
+state it sees is PAUSE, and the printer's PAUSE -> RUNNING was taken for a new
+print: a second archive, left "printing". It is now the restart recovery that
+a print found RUNNING at startup already gets (#1304, #1485).
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+FILE = "chocolate-bite-buddy_plate_2.3mf"
+
+
+@pytest.fixture
+def client():
+    client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
+    client.starts = []
+    client.recovered = []
+    client.completions = []
+    client.on_print_start = client.starts.append
+    client.on_print_running_observed = client.recovered.append
+    client.on_print_complete = client.completions.append
+    return client
+
+
+def _push(client, state: str, file: str | None = FILE) -> None:
+    payload = {"gcode_state": state}
+    if file is not None:
+        payload.update(gcode_file=file, subtask_name=file)
+    client._process_message({"print": payload})
+
+
+def test_the_resume_is_not_a_new_print(client):
+    _push(client, "PAUSE")
+    # The resume frame carries no file name, as on the reporter's A1 mini.
+    _push(client, "RUNNING", file=None)
+
+    assert client.starts == []
+    assert len(client.recovered) == 1
+    assert client.recovered[0]["filename"] == FILE
+
+
+def test_the_resumed_print_still_completes(client):
+    _push(client, "PAUSE")
+    _push(client, "RUNNING", file=None)
+    _push(client, "FINISH")
+
+    assert [c["status"] for c in client.completions] == ["completed"]
+
+
+def test_a_later_pause_and_resume_is_still_the_same_print(client):
+    _push(client, "PAUSE")
+    _push(client, "RUNNING", file=None)
+    _push(client, "PAUSE")
+    _push(client, "RUNNING")
+
+    assert client.starts == []
+
+
+def test_a_print_after_the_paused_one_ends_is_new(client):
+    _push(client, "PAUSE")
+    _push(client, "IDLE", file="")
+    _push(client, "RUNNING", file="next.3mf")
+
+    assert len(client.starts) == 1
+    assert client.starts[0]["filename"] == "next.3mf"
+
+
+def test_a_next_print_paused_before_it_runs_is_still_new(client):
+    _push(client, "PAUSE")
+    _push(client, "IDLE", file="")
+    _push(client, "PREPARE", file="next.3mf")
+    _push(client, "PAUSE", file="next.3mf")
+    _push(client, "RUNNING", file="next.3mf")
+
+    assert [s["filename"] for s in client.starts] == ["next.3mf"]
+
+
+def test_a_print_started_after_startup_is_new(client):
+    _push(client, "IDLE", file="")
+    _push(client, "RUNNING")
+
+    assert len(client.starts) == 1
+    assert client.recovered == []
+
+
+def test_a_pause_seen_while_running_resumes_quietly(client):
+    _push(client, "IDLE", file="")
+    _push(client, "RUNNING")
+    _push(client, "PAUSE")
+    _push(client, "RUNNING")
+
+    assert len(client.starts) == 1
+    assert client.recovered == []