Files
bambuddy/backend/app/services/print_dispatch_context.py
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

69 lines
2.6 KiB
Python

"""Whether Bambuddy injected End G-code into the print now running (#2547).
The finish-photo path has to know one thing at print completion that no MQTT
field reports: did this print end with user End G-code? If it did, a SwapMod
snippet may already have ejected the plate, so the scene in front of the camera
at ``gcode_state=FINISH`` is not the finished print and the photo must come from
the in-print frame bank instead (#1867).
Only the dispatcher ever sees this, so it is recorded here in two steps:
1. ``mark_pending`` when the scheduler injects an End G-code snippet.
2. ``adopt`` when the printer reports a print starting, which moves the pending
flag onto the running print and consumes it.
The two steps exist so the flag can never outlive its print. A print Bambuddy
did not dispatch — started from the slicer, the SD card, or the printer's own
screen — finds no pending flag and correctly adopts ``False``, instead of
inheriting the answer from whatever ran before it.
In-memory and best-effort: a restart mid-print loses the flag, and ``False`` is
the safe way to be wrong (a live grab that might show a swapped plate, rather
than silently substituting a mid-print frame).
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# Printers the scheduler has injected End G-code for, awaiting a print start.
_pending: set[int] = set()
# Printers whose *currently running* print has injected End G-code.
_active: set[int] = set()
def mark_pending(printer_id: int) -> None:
"""Record that the job now being sent to ``printer_id`` has End G-code."""
_pending.add(printer_id)
logger.debug("[DISPATCH-CTX] printer %s: End G-code injected, awaiting print start", printer_id)
def adopt(printer_id: int) -> bool:
"""Bind any pending flag to the print that just started, and return it.
Called once per print start. Always writes ``_active`` — including the
``False`` case — so a print Bambuddy didn't dispatch clears its
predecessor's flag rather than inheriting it.
"""
injected = printer_id in _pending
_pending.discard(printer_id)
if injected:
_active.add(printer_id)
logger.debug("[DISPATCH-CTX] printer %s: running print has injected End G-code", printer_id)
else:
_active.discard(printer_id)
return injected
def end_gcode_injected(printer_id: int) -> bool:
"""True if the print currently running on ``printer_id`` has End G-code."""
return printer_id in _active
def clear(printer_id: int) -> None:
"""Forget everything about this printer (disconnect, removal, tests)."""
_pending.discard(printer_id)
_active.discard(printer_id)