Files
bambuddy/backend/tests/unit/services/test_print_dispatch_context.py
T
maziggy db6cdb0745 fix(camera): take the finish photo when the print ends, not when its last layer starts (#2547)
The photo fired the moment layer_num reached total_layer_num. That edge is
where the printer *starts* its final layer, not where it finishes it: the
reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
three minutes and seventeen seconds and one filament change before the print
actually ended, so the frame caught the toolhead mid-print over the model.

The trigger also latched _finish_photo_captured, which locked out both the
stage-22 and FINISH triggers for the rest of the print — so on firmware that
never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
nothing could replace the bad frame.

Remove the last-layer trigger. The photo is now taken at the FINISH-state
trigger, which every model sends and which lands after the toolhead parks.

Since Bambu's end G-code drops the plate ~100mm just before that, restore the
framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
settle, capture, then drop it back so the print is as reachable as the printer
left it. Absolute is the safety argument — that Z is a height the toolhead
occupied seconds earlier, so it is inside the travel limits by construction and
leaves the nozzle above the part, and it is unambiguous across model families
because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.

The height is only trusted when two independent sources agree: the archive is
matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
layer count the printer reported over MQTT. Matching on "most recent archive
for this printer" was not safe — on_print_complete pops the _active_prints
binding concurrently, and a print Bambuddy failed to archive would have
resolved to its predecessor. A wrong height is the one failure that could drive
the nozzle into the model.

The move is additionally skipped when the print height is unknown, when a queue
item is pending for the printer, when the printer has left FINISH, and when the
new finish_photo_restore_plate setting is off.

for every FINISH-state capture — which is what shipped the mid-print photo —
the bank is used only when the dispatcher recorded that it injected End G-code
into this print, since a SwapMod snippet may have ejected the plate. The flag is
handed over in two steps (mark_pending at dispatch, adopt at print start) so it
can never outlive its print: a job started from the slicer or SD card adopts
False rather than inheriting its predecessor's answer. Those prints also skip
the plate move outright, bank or no bank.

The bank now refreshes on mc_percent advances as well as layer changes, via a
new on_print_progress callback. Layer changes stop the instant the final layer
begins, which left the #1867 fallback frame stale by the whole length of that
layer; progress keeps ticking there and freezes before the End G-code runs, so
a swapped plate still cannot reach the bank. The last-layer throttle exemption
is dropped, since it would now fire a grab on every percent tick.

On the timelapse path the moment producer returns early, so the consumer does
the restore itself before its live-grab fallback — the documented usual outcome
on P1-series, where the video has not transferred by the time the notification
goes out and the shipped photo was of an already-dropped plate. The two waits
are now derived from the settle window and the video poll timeout rather than
hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
mid-settle.

extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
It returns None for missing, unparseable, zero and negative values so callers
must treat "don't know" as such rather than defaulting.
2026-07-31 12:55:05 +02:00

78 lines
2.7 KiB
Python

"""Tests for the injected-End-G-code flag the finish photo depends on (#2547).
The flag decides whether the finish photo comes from the camera (the print is
still on the plate) or from the in-print frame bank (a SwapMod snippet ejected
it — #1867). Getting it wrong in either direction ships the wrong photo, so the
two-step pending/adopt handoff exists to guarantee the flag can never outlive
the print it was recorded for.
"""
import pytest
from backend.app.services import print_dispatch_context
@pytest.fixture(autouse=True)
def _clean():
for printer_id in (1, 2):
print_dispatch_context.clear(printer_id)
yield
for printer_id in (1, 2):
print_dispatch_context.clear(printer_id)
def test_unknown_printer_reports_no_injection():
assert print_dispatch_context.end_gcode_injected(1) is False
def test_pending_flag_only_counts_once_the_print_starts():
"""Dispatch can fail between upload and start. Until the printer confirms a
print running, the flag must not affect anything."""
print_dispatch_context.mark_pending(1)
assert print_dispatch_context.end_gcode_injected(1) is False
assert print_dispatch_context.adopt(1) is True
assert print_dispatch_context.end_gcode_injected(1) is True
def test_adopting_consumes_the_pending_flag():
"""A second print must not inherit the first print's snippet."""
print_dispatch_context.mark_pending(1)
print_dispatch_context.adopt(1)
assert print_dispatch_context.adopt(1) is False
assert print_dispatch_context.end_gcode_injected(1) is False
def test_a_print_we_did_not_dispatch_clears_the_previous_flag():
"""The failure this two-step design exists to prevent: a print started from
the slicer or SD card right after a SwapMod job would otherwise inherit its
flag and get a mid-print banked frame instead of its own finish photo."""
print_dispatch_context.mark_pending(1)
print_dispatch_context.adopt(1)
assert print_dispatch_context.end_gcode_injected(1) is True
# Next print start, with nothing pending — i.e. Bambuddy didn't send it.
assert print_dispatch_context.adopt(1) is False
assert print_dispatch_context.end_gcode_injected(1) is False
def test_printers_do_not_share_flags():
print_dispatch_context.mark_pending(1)
print_dispatch_context.adopt(1)
assert print_dispatch_context.end_gcode_injected(1) is True
assert print_dispatch_context.end_gcode_injected(2) is False
def test_clear_forgets_pending_and_active():
print_dispatch_context.mark_pending(1)
print_dispatch_context.adopt(1)
print_dispatch_context.mark_pending(1)
print_dispatch_context.clear(1)
assert print_dispatch_context.end_gcode_injected(1) is False
assert print_dispatch_context.adopt(1) is False