mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
This commit is contained in:
commit
35e5d0104e
31 changed files with 1423 additions and 173 deletions
|
|
@ -31,6 +31,7 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
- **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest.
|
||||
|
||||
### Fixed
|
||||
- **The print-complete photo showed the toolhead still printing, three minutes before the print ended (#2547, reporter @anthonyma94)** — The Discord photo caught the model mid-print with the head over it, instead of the finished print. **Root cause.** Bambuddy fired the photo the moment `layer_num` reached `total_layer_num`. That edge is the moment the printer *starts* its final layer, not the moment it finishes it: on the reporter's H2C it arrived at 92% with two minutes of print still to run, and the last layer took three minutes and seventeen seconds including a filament change. Worse, that trigger latched, which locked out both of the triggers that fire at a genuine end of print — so on printers that never report an end-of-print filament unload (H2C and A1 Mini confirmed) there was no way back to a correct photo. **Fix.** The last-layer trigger is gone. The photo is taken when the print reports itself finished, which is a signal every model sends and which lands after the toolhead has parked. Since the printer's own end G-code drops the plate about 100 mm just before that, Bambuddy now raises it back to just above the last printed layer, takes the photo, then lowers it again — restoring the framing asked for in #1145, #1397 and #1565. The plate move is an absolute Z to a height the toolhead occupied seconds earlier, so it stays inside the travel limits and keeps the nozzle above the part, and it is skipped outright unless Bambuddy can confirm the height belongs to this print: the sliced file is matched by print name, and its layer count is cross-checked against the layer count the printer reported. It is also skipped when another job is queued, when the printer has moved on, and when the new **Restore plate for finish photo** setting is off. Prints whose End G-code Bambuddy injected — SwapMod plate swaps and similar — are detected automatically and keep using a frame from during the print, since their model has left the bed by then (#1867); that frame now also refreshes through the final layer instead of freezing when it began. Prints that record a timelapse normally source the photo from the video's last frame and need no move at all, but when the video has not transferred in time — the usual outcome on P1-series — the live photo that ships in the notification now gets the same plate restore, so it is no longer a shot of an empty-looking lowered bed. The `stg_cur=22` trigger is left in place for any firmware that does emit it, but the bundle survey above still applies — in practice every model reaches the finish-state path. Translated in all locales; wiki updated. Covered by backend tests for the removed trigger, the two surviving ones, the plate move and every condition that suppresses it, and by frontend tests for the new setting.
|
||||
- **A model sliced for PETG printed as PLA, and the print dialog then refused to match PETG (#2712, reporter @kpp39)** — Slicing a MakerWorld model with a PETG profile produced G-code the printer wanted PLA for, and the Filament match step offered no way to correct it. **Root cause.** The list of filaments the slice dialog shows is positional: the first row is the printer's first slot, the second row its second, and so on down to the slicer itself. For a model that already carries slicing information, Bambuddy listed only the slots that print — which is the right answer when you are starting a print and Bambuddy has to match spools in the AMS, and the wrong one here. The reported model declares four filaments and paints with the fourth alone, so the dialog showed a single row; the PETG chosen in it became the *first* slot, and the fourth — the one the model actually prints with — kept the profile baked into the downloaded file. The result was a genuine PLA print, so the Filament match step was right to insist on PLA. **Fix.** When picking profiles for a slice, the dialog now lists every slot the project declares, with the ones this plate doesn't print with shown greyed out as before, so each row lines up with the slot it stands for and a choice made in the fourth row reaches the fourth slot. Starting a print is untouched and still asks only for the spools the job needs. Covered by tests for a source whose only printed slot is the fourth, for the print path keeping the shorter list, and for the chosen profile arriving in the right position.
|
||||
- **A finished slice produced a stream of a dozen "Sliced ..." notifications** — One slice reported itself complete over and over, a notification every second and a half for as long as twenty seconds. **Root cause.** While a slice runs, Bambuddy asks the server how it is getting on every 1.5 seconds — but it never waited for an answer before asking again. Slicing a large project keeps the server busy for seconds at a time, so those questions piled up unanswered, each one still believing the job was running. When the server caught up it answered all of them at once, and every single answer was treated as the moment the slice finished: one notification each, one list refresh each. The bigger the project, the longer the pile and the more notifications. **Fix.** Bambuddy now waits for an answer before asking the next question, so nothing can pile up and a busy server isn't asked to do more work while it is already behind. A job's completion is also recorded once and only once, and a check that was already in flight when the tracker restarts now stops instead of finishing its work — either of which is enough on its own to keep a duplicate off the screen. Covered by tests for a server stalled across many intervals, for two slices finishing where one restarts the tracker, and for the same job being tracked twice in a row still reporting both times.
|
||||
- **Slicing a single-plate project failed on filament slots the plate never prints with (#2711, reporter @kpp39, also seen by @phi-schi)** — Sending a MakerWorld project to the slicer was rejected with "filament preset ... (slot 1) is not compatible with printer ...", naming a slot the model doesn't use, and the slice modal deliberately locks the dropdowns for unused slots so there was no way to correct it by hand. **Root cause.** A project can declare more filaments than any one plate paints with — the reported model declares four and uses one — and the slicer validates every filament it is handed, not just the ones the print touches. Bambuddy already rewrote those unused entries to match a slot the plate really uses, but only when the plate number was part of the request. The modal omits it for single-plate projects, since there is no plate to choose, so the rewrite never ran for them — which is every model imported from MakerWorld. The three idle slots therefore arrived carrying whatever the source file had baked in, in this case profiles for an entirely different printer, and the slicer refused the job on the first one. **Fix.** A missing plate number now means the first plate, which is what it means everywhere else in the slicing path, so single-plate projects get the same treatment multi-plate ones already had. **Also fixed:** "Slice all plates" reached the same code, where it isn't a plate number at all. On a project with a dedicated support filament that combination could rewrite every colour to the support material and silently slice a multi-colour model in one filament — it is now excluded, since across all plates no slot is unused. Covered by tests for a single-plate slice with no plate number in the request, for slice-all leaving every slot untouched, and for the support-filament case specifically.
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"auto_archive",
|
||||
"save_thumbnails",
|
||||
"capture_finish_photo",
|
||||
"finish_photo_restore_plate",
|
||||
"spoolman_enabled",
|
||||
"spoolman_disable_weight_sync",
|
||||
"spoolman_report_partial_usage",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ from backend.app.core.database import async_session, engine, init_db
|
|||
from backend.app.core.tasks import spawn_background_task
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services import print_dispatch_context
|
||||
from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
|
||||
from backend.app.services.archive_purge import archive_purge_service
|
||||
from backend.app.services.bambu_ftp import (
|
||||
|
|
@ -365,14 +366,17 @@ _stage22_finish_frames: dict[int, bytes] = {}
|
|||
_stage22_finish_in_flight: dict[int, asyncio.Event] = {}
|
||||
|
||||
# #1867: rolling "last in-print camera frame" per printer. Refreshed on
|
||||
# layer-change while the model is still printing, then consumed by the
|
||||
# FINISH-state finish-photo path. Firmware that never emits `stg_cur=22`
|
||||
# (A1 Mini, confirmed) only reaches `on_finish_photo_moment` at the
|
||||
# gcode_state=FINISH transition — which Bambu reports AFTER the user End
|
||||
# G-code (e.g. SwapMod plate-swap) has run, so a live grab there captures the
|
||||
# swapped/empty plate. Banking is layer-driven, so it naturally freezes at the
|
||||
# final object layer: the End G-code emits no further layer_num increases, so
|
||||
# the last banked frame is always the finished print before the swap.
|
||||
# layer-change and on print-progress advances (#2547) while the model is still
|
||||
# printing, then consumed by the FINISH-state finish-photo path when the
|
||||
# dispatcher recorded that it injected End G-code into this print. Bambu
|
||||
# reports gcode_state=FINISH AFTER the user End G-code (e.g. SwapMod
|
||||
# plate-swap) has run, so a live grab there would capture the swapped/empty
|
||||
# plate.
|
||||
#
|
||||
# The load-bearing property: both drivers are print telemetry that stops before
|
||||
# the End G-code executes — no further layer_num increases, and mc_percent
|
||||
# freezes — so the last banked frame is always the finished print before the
|
||||
# swap. Anything added as a third driver must hold that same property.
|
||||
_inprint_frame_bank: dict[int, bytes] = {}
|
||||
# Monotonic timestamp of the last banked frame per printer — throttles banking
|
||||
# so tall prints don't add a camera grab on every layer.
|
||||
|
|
@ -2275,13 +2279,21 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
|
|||
async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
|
||||
"""#1867: bank a recent in-print camera frame for the finish photo.
|
||||
|
||||
Called on every layer change. Grabs one frame (throttled) into
|
||||
``_inprint_frame_bank`` so the FINISH-state finish-photo path has a
|
||||
pre-swap image on firmware that never emits ``stg_cur=22``. Because it is
|
||||
driven by layer_num increases, banking stops the instant printing ends and
|
||||
the End G-code (e.g. SwapMod plate swap) runs — no further layer changes
|
||||
arrive — so the last banked frame is the finished print, not the swapped
|
||||
plate. Best-effort: any failure just leaves the previous banked frame.
|
||||
Called on every layer change and (#2547) on every print-progress advance.
|
||||
Grabs one frame (throttled) into ``_inprint_frame_bank`` so the finish-photo
|
||||
path has a pre-End-G-code image for prints that end with a plate swap.
|
||||
|
||||
Both drivers are print telemetry that stops the instant printing ends: no
|
||||
further layers, and progress freezes before the End G-code (e.g. SwapMod
|
||||
plate swap) executes. So the last banked frame is always the finished print,
|
||||
never the swapped plate — that property is what the #1867 path relies on and
|
||||
it must survive any change to the throttle below.
|
||||
|
||||
Layer changes alone were not enough: they stop when the *final* layer
|
||||
begins, which on a three-minute last layer left the bank stale by the whole
|
||||
length of that layer (#2547). Progress keeps ticking through it.
|
||||
|
||||
Best-effort: any failure just leaves the previous banked frame.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
client = printer_manager.get_client(printer_id)
|
||||
|
|
@ -2293,12 +2305,16 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
|
|||
if state.mc_print_sub_stage not in (None, 0):
|
||||
return
|
||||
|
||||
total = state.total_layers or 0
|
||||
is_last_layer = total > 0 and layer_num >= total
|
||||
# #2547: throttled uniformly, with no last-layer exemption. The old code
|
||||
# bypassed the throttle on the final layer to guarantee a fresh frame there;
|
||||
# now that progress advances also drive banking, that exemption would fire a
|
||||
# camera grab on every percent tick of the last layer. Bambu printers accept
|
||||
# one RTSP client at a time, so each grab contends with the live view.
|
||||
now = time.monotonic()
|
||||
last = _inprint_frame_bank_ts.get(printer_id, 0.0)
|
||||
if not is_last_layer and (now - last) < _INPRINT_BANK_MIN_INTERVAL:
|
||||
if (now - last) < _INPRINT_BANK_MIN_INTERVAL:
|
||||
return
|
||||
total = state.total_layers or 0
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
|
|
@ -2455,6 +2471,10 @@ async def on_print_start(printer_id: int, data: dict):
|
|||
# the previous job's banked frame.
|
||||
_inprint_frame_bank.pop(printer_id, None)
|
||||
_inprint_frame_bank_ts.pop(printer_id, None)
|
||||
# #2547: bind (or clear) the "this print ends with injected End G-code" flag.
|
||||
# Unconditional, so a print Bambuddy didn't dispatch drops the previous
|
||||
# print's flag instead of inheriting it.
|
||||
print_dispatch_context.adopt(printer_id)
|
||||
|
||||
# Cancel any active bed cooldown waiter for this printer
|
||||
if _bed_cool_waiters.pop(printer_id, None):
|
||||
|
|
@ -4304,6 +4324,207 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
|
|||
return reconciled
|
||||
|
||||
|
||||
# #2547: clearance left between the nozzle and the top of the print when the
|
||||
# plate is commanded back into camera framing. The nozzle is parked away from
|
||||
# the part by then, so this is belt-and-braces against a max_z_height that
|
||||
# under-reports (e.g. a slicer that excludes a final Z hop).
|
||||
_PLATE_RESTORE_CLEARANCE_MM = 10.0
|
||||
# How far below the restored position to drop the plate again afterwards, so
|
||||
# the print is as reachable as Bambu's own end G-code leaves it. Matches the
|
||||
# stock `G1 Z{max_layer_z + 100}`; the firmware clamps it to the travel limit
|
||||
# on machines with less headroom.
|
||||
_PLATE_PARK_DROP_MM = 100.0
|
||||
# Feedrate for both moves. F600 is exactly what Bambu's own end G-code uses on
|
||||
# this axis, so it is a proven-safe speed for the full travel.
|
||||
_PLATE_RESTORE_FEEDRATE = 600
|
||||
# Time allowed for the plate to reach the restored position before the camera
|
||||
# grab. Sized for the ~100 mm the stock end G-code drops at F600 (10 mm/s).
|
||||
_PLATE_RESTORE_SETTLE_SECONDS = 12.0
|
||||
# How long `_background_finish_photo` waits for this producer. Must cover the
|
||||
# settle window plus a worst-case RTSP grab (15s), and stay below the
|
||||
# notification path's own photo wait so a slow producer degrades to a
|
||||
# photo-less notification rather than a missed one.
|
||||
_FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0
|
||||
|
||||
|
||||
async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float | None:
|
||||
"""Height of the print that just finished on ``printer_id``, or None (#2547).
|
||||
|
||||
This number becomes the target of a real Z move, so every step here refuses
|
||||
rather than guesses. A height belonging to some *other* print is the one
|
||||
failure that could drive the nozzle into the model: 20 mm carried onto a
|
||||
200 mm print would command the plate up through the part.
|
||||
|
||||
Two independent things therefore have to agree before a height is returned:
|
||||
|
||||
1. **Identity.** The archive is matched by the finished print's own
|
||||
``subtask_name``, by equality rather than a ``LIKE``, so "Cube" can never
|
||||
resolve to "Cube v2". Matching on "most recent archive for this printer"
|
||||
is not good enough — ``on_print_complete`` pops the ``_active_prints``
|
||||
binding concurrently with us, and a print Bambuddy failed to archive
|
||||
would silently resolve to its predecessor.
|
||||
2. **Corroboration.** The archive's layer count (parsed from the 3MF) has to
|
||||
match the layer count the printer itself reported over MQTT for the print
|
||||
that just ended. These come from genuinely different sources, so a
|
||||
mismatch means the row is not this print, whatever its name says.
|
||||
|
||||
``completed`` is accepted alongside ``printing`` only because
|
||||
``on_print_complete`` may already have flipped the status by the time we
|
||||
run; the identity check above is what actually selects the row.
|
||||
"""
|
||||
subtask_name = (data.get("subtask_name") or "").strip()
|
||||
if not subtask_name:
|
||||
# Nothing to identify the print by — refuse rather than fall back to
|
||||
# "whatever ran last on this printer".
|
||||
logger.info("[PLATE-RESTORE] printer %s: print has no name to match on — skipping", printer_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.utils.threemf_tools import extract_max_z_height_from_3mf
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(
|
||||
PrintArchive.printer_id == printer_id,
|
||||
PrintArchive.status.in_(("printing", "completed")),
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
or_(
|
||||
PrintArchive.print_name == subtask_name,
|
||||
PrintArchive.filename == subtask_name,
|
||||
PrintArchive.filename == f"{subtask_name}.3mf",
|
||||
PrintArchive.filename == f"{subtask_name}.gcode.3mf",
|
||||
),
|
||||
)
|
||||
.order_by(PrintArchive.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
if archive is None or not archive.file_path:
|
||||
logger.info("[PLATE-RESTORE] printer %s: no archive matches %r — skipping", printer_id, subtask_name)
|
||||
return None
|
||||
|
||||
client = printer_manager.get_client(printer_id)
|
||||
reported_layers = getattr(getattr(client, "state", None), "total_layers", None)
|
||||
if reported_layers and archive.total_layers and reported_layers != archive.total_layers:
|
||||
logger.warning(
|
||||
"[PLATE-RESTORE] printer %s: archive %s says %s layers but the printer reported %s "
|
||||
"— refusing to move the plate on a height that may not be this print's",
|
||||
printer_id,
|
||||
archive.id,
|
||||
archive.total_layers,
|
||||
reported_layers,
|
||||
)
|
||||
return None
|
||||
|
||||
path = Path(archive.file_path)
|
||||
if not path.is_absolute():
|
||||
path = Path(app_settings.data_dir) / path
|
||||
return await asyncio.to_thread(extract_max_z_height_from_3mf, path, archive.plate_id or 1)
|
||||
except Exception as e:
|
||||
logger.debug("[PLATE-RESTORE] printer %s: no usable print height: %s", printer_id, e)
|
||||
return None
|
||||
|
||||
|
||||
async def _restore_plate_for_finish_photo(printer_id: int, max_z_height: float, logger) -> bool:
|
||||
"""Raise the plate back into camera framing before the finish photo (#2547).
|
||||
|
||||
Bambu's end G-code drops the plate ~100 mm as the last thing it does, so by
|
||||
the time ``gcode_state`` reaches FINISH the finished print sits far below
|
||||
the camera's natural framing — the complaint behind #1145, #1397 and #1565.
|
||||
This commands an absolute ``G1 Z`` back to just above the last printed
|
||||
layer.
|
||||
|
||||
Absolute, not relative, is the whole safety argument. ``max_z_height +
|
||||
clearance`` is a height the toolhead was physically at seconds earlier, so
|
||||
it is inside the travel limits by construction and leaves the nozzle above
|
||||
the part. It is also unambiguous across model families: Z is the
|
||||
nozzle-to-bed gap whether the bed moves (X1/P1/H2) or the toolhead does
|
||||
(A1), so unlike the relative bed-jog path (#1334) there is no sign to get
|
||||
wrong. ``M211`` is never touched — see the bed-jog docstring for why
|
||||
(#2579).
|
||||
|
||||
Returns True if the move was sent and waited out, False if it was skipped.
|
||||
"""
|
||||
client = printer_manager.get_client(printer_id)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
# Re-read state immediately before commanding motion. If the queue has
|
||||
# already started the next print, the printer is no longer ours to move.
|
||||
state = getattr(client, "state", None)
|
||||
if state is None or state.state != "FINISH":
|
||||
logger.info(
|
||||
"[PLATE-RESTORE] printer %s is in state %s, not FINISH — skipping",
|
||||
printer_id,
|
||||
getattr(state, "state", "unknown"),
|
||||
)
|
||||
return False
|
||||
|
||||
target_z = max_z_height + _PLATE_RESTORE_CLEARANCE_MM
|
||||
if not client.send_gcode(f"G90\nG1 Z{target_z:.2f} F{_PLATE_RESTORE_FEEDRATE}"):
|
||||
logger.warning("[PLATE-RESTORE] printer %s: send failed — capturing where it is", printer_id)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"[PLATE-RESTORE] printer %s: plate to Z%.2f (print top %.2f + %.1f clearance), settling %.0fs",
|
||||
printer_id,
|
||||
target_z,
|
||||
max_z_height,
|
||||
_PLATE_RESTORE_CLEARANCE_MM,
|
||||
_PLATE_RESTORE_SETTLE_SECONDS,
|
||||
)
|
||||
await asyncio.sleep(_PLATE_RESTORE_SETTLE_SECONDS)
|
||||
return True
|
||||
|
||||
|
||||
def _park_plate_after_finish_photo(printer_id: int, max_z_height: float, logger) -> None:
|
||||
"""Drop the plate again after the finish photo (#2547).
|
||||
|
||||
Without this the user walks up to a finished print sitting just under the
|
||||
nozzle, which is exactly the position Bambu's end G-code goes out of its way
|
||||
to avoid — awkward to lift the plate out, and easy to knock the toolhead.
|
||||
Fire-and-forget: if it doesn't land, the plate is merely high, and the next
|
||||
print homes anyway.
|
||||
"""
|
||||
client = printer_manager.get_client(printer_id)
|
||||
state = getattr(client, "state", None) if client else None
|
||||
if client is None or state is None or state.state != "FINISH":
|
||||
return
|
||||
client.send_gcode(f"G90\nG1 Z{max_z_height + _PLATE_PARK_DROP_MM:.2f} F{_PLATE_RESTORE_FEEDRATE}")
|
||||
logger.debug("[PLATE-RESTORE] printer %s: plate returned to unload height", printer_id)
|
||||
|
||||
|
||||
async def _plate_restore_is_blocked_by_queue(printer_id: int) -> bool:
|
||||
"""True if a queue item is about to take this printer (#2547).
|
||||
|
||||
The scheduler dispatches the next job the moment a print completes, and a
|
||||
plate move interleaved with a print start is not a race worth having. The
|
||||
state re-check in ``_restore_plate_for_finish_photo`` closes the tail of
|
||||
this window; this closes the head of it.
|
||||
"""
|
||||
try:
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem.id)
|
||||
.where(
|
||||
PrintQueueItem.printer_id == printer_id,
|
||||
PrintQueueItem.status.in_(("pending", "printing")),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
except Exception as e:
|
||||
# Fail closed: if we can't tell, don't move the plate.
|
||||
logging.getLogger(__name__).debug(
|
||||
"[PLATE-RESTORE] queue check failed for printer %s: %s — skipping restore", printer_id, e
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def on_finish_photo_moment(printer_id: int, data: dict):
|
||||
"""Pre-capture a finish photo when the printer enters stage 22 / FINISH (#1721).
|
||||
|
||||
|
|
@ -4349,6 +4570,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|||
producer_done = asyncio.Event()
|
||||
_stage22_finish_in_flight[printer_id] = producer_done
|
||||
|
||||
# #2547: set once the plate has actually been raised, and read by the
|
||||
# `finally` below. Declared out here so a failure anywhere after the move —
|
||||
# a camera timeout, a DB error — still lowers the plate again.
|
||||
restore_max_z: float | None = None
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
|
@ -4359,6 +4585,9 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|||
logger.info("[FINISH-PHOTO-MOMENT] capture_finish_photo disabled — skipping pre-capture")
|
||||
return
|
||||
|
||||
restore_setting = await get_setting(db, "finish_photo_restore_plate")
|
||||
restore_plate_enabled = restore_setting is None or restore_setting.lower() == "true"
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if printer is None:
|
||||
|
|
@ -4375,22 +4604,64 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|||
# exactly one rotation in `_stage22_finish_frames` either way.
|
||||
frame_already_rotated = False
|
||||
|
||||
# #1867: on the FINISH-state fallback the End G-code (e.g. SwapMod
|
||||
# plate-swap) has already run, so a live grab now captures the swapped
|
||||
# or empty plate. Prefer the banked in-print frame — the finished
|
||||
# print from the last object layer, before the swap. Only for
|
||||
# `finish_state`: the `stage_22` and `last_layer` triggers fire before
|
||||
# the swap and give cleaner (parked-toolhead) framing via a live grab.
|
||||
if trigger == "finish_state":
|
||||
# On the FINISH-state path the End G-code has already run, and two very
|
||||
# different situations arrive here needing opposite answers.
|
||||
#
|
||||
# #1867: if Bambuddy injected End G-code into this print, a SwapMod
|
||||
# snippet may have ejected the plate — the scene in front of the camera
|
||||
# is no longer the finished print, and no amount of moving the plate
|
||||
# brings it back. Use the banked in-print frame instead.
|
||||
#
|
||||
# #2547: otherwise the print is still sitting there, just ~100 mm lower
|
||||
# than the camera frames well, and the toolhead is parked out of the
|
||||
# way. That is the *best* moment available on firmware that never emits
|
||||
# stage 22 (H2C, A1 Mini) — so capture live, after putting the plate
|
||||
# back. Preferring the bank here unconditionally, as this code used to,
|
||||
# is what shipped a mid-print photo with the toolhead over the part.
|
||||
if trigger == "finish_state" and print_dispatch_context.end_gcode_injected(printer_id):
|
||||
banked = _inprint_frame_bank.get(printer_id)
|
||||
if banked:
|
||||
frame_bytes = banked
|
||||
frame_already_rotated = True
|
||||
logger.info(
|
||||
"[FINISH-PHOTO-MOMENT] using banked in-print frame (%d bytes) — "
|
||||
"avoids post-swap live grab on stage-22-less firmware",
|
||||
"[FINISH-PHOTO-MOMENT] End G-code was injected — using banked in-print "
|
||||
"frame (%d bytes) instead of a post-swap live grab",
|
||||
len(banked),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[FINISH-PHOTO-MOMENT] End G-code was injected for printer %s but the "
|
||||
"in-print bank is empty — falling back to a live grab, which may show a "
|
||||
"swapped or empty plate",
|
||||
printer_id,
|
||||
)
|
||||
|
||||
# `restore_max_z` is set only once the plate is actually up, because the
|
||||
# `finally` reads it to decide whether it owes a move back down.
|
||||
#
|
||||
# Never on a print whose End G-code Bambuddy injected, even when the bank
|
||||
# came up empty above: that machine may have just ejected its plate, and
|
||||
# driving Z into whatever a swap mechanism is doing is not a risk worth
|
||||
# taking for a photo of a bed we already know may be bare.
|
||||
if (
|
||||
frame_bytes is None
|
||||
and trigger == "finish_state"
|
||||
and restore_plate_enabled
|
||||
and not print_dispatch_context.end_gcode_injected(printer_id)
|
||||
):
|
||||
wants_restore = await _max_z_for_current_print(printer_id, data, logger)
|
||||
if wants_restore is None:
|
||||
logger.info(
|
||||
"[PLATE-RESTORE] printer %s: print height unknown — capturing without restore",
|
||||
printer_id,
|
||||
)
|
||||
elif await _plate_restore_is_blocked_by_queue(printer_id):
|
||||
logger.info(
|
||||
"[PLATE-RESTORE] printer %s has queued work — skipping plate restore",
|
||||
printer_id,
|
||||
)
|
||||
elif await _restore_plate_for_finish_photo(printer_id, wants_restore, logger):
|
||||
restore_max_z = wants_restore
|
||||
|
||||
if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
|
||||
from backend.app.api.routes.camera import live_frame_for_capture
|
||||
|
|
@ -4447,6 +4718,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|||
"[FINISH-PHOTO-MOMENT] no frame captured for printer %s — post-completion fallback will retry",
|
||||
printer_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[FINISH-PHOTO-MOMENT] pre-capture failed for printer %s: %s",
|
||||
|
|
@ -4454,6 +4726,13 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
|||
e,
|
||||
)
|
||||
finally:
|
||||
# #2547: we raised the plate, so we own lowering it — including when the
|
||||
# capture above failed or threw partway through.
|
||||
if restore_max_z is not None:
|
||||
try:
|
||||
_park_plate_after_finish_photo(printer_id, restore_max_z, logger)
|
||||
except Exception as e:
|
||||
logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e)
|
||||
# #1790: always unblock the consumer's bounded wait — whether we stored
|
||||
# a frame, gave up, or hit an exception. Local ref means cleanup of the
|
||||
# dict entry by the consumer doesn't affect signalling.
|
||||
|
|
@ -5270,6 +5549,11 @@ async def on_print_complete(printer_id: int, data: dict):
|
|||
|
||||
async def _background_finish_photo() -> str | None:
|
||||
"""Capture finish photo in background. Returns photo filename if captured."""
|
||||
# #2547: set once this function has raised the plate itself (the
|
||||
# timelapse path, where the moment producer returned without doing it).
|
||||
# Declared out here so the `finally` can lower it again no matter where
|
||||
# the capture below fails.
|
||||
plate_restored_z: float | None = None
|
||||
try:
|
||||
logger.info("[PHOTO-BG] Starting finish photo capture for archive %s", archive_id)
|
||||
|
||||
|
|
@ -5345,10 +5629,16 @@ async def on_print_complete(printer_id: int, data: dict):
|
|||
# producer's still-in-flight grab (single-client RTSP
|
||||
# on Bambu printers). Wait for the producer to finish
|
||||
# or give up before touching the cache.
|
||||
#
|
||||
# #2547: 20s was enough when the producer only ever grabbed a
|
||||
# frame. It now also raises the plate first, which costs the
|
||||
# settle window before the grab even starts — so the budget has
|
||||
# to cover settle + a worst-case 15s RTSP timeout, and still sit
|
||||
# under the notification's own photo wait below.
|
||||
in_flight = _stage22_finish_in_flight.pop(printer_id, None)
|
||||
if in_flight is not None:
|
||||
try:
|
||||
await asyncio.wait_for(in_flight.wait(), timeout=20.0)
|
||||
await asyncio.wait_for(in_flight.wait(), timeout=_FINISH_PHOTO_PRODUCER_WAIT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback",
|
||||
|
|
@ -5371,6 +5661,37 @@ async def on_print_complete(printer_id: int, data: dict):
|
|||
len(cached_frame),
|
||||
)
|
||||
|
||||
# #2547: the timelapse path reaches the live grab below whenever the
|
||||
# video hasn't landed in time — the documented usual outcome on
|
||||
# P1-series, where transfers are slowest. `on_finish_photo_moment`
|
||||
# returned early for those prints without raising the plate, so
|
||||
# without this the photo that actually ships in the notification is
|
||||
# of an already-dropped plate: exactly the framing #1145/#1397/#1565
|
||||
# asked us to fix. The archive still gets the better video frame
|
||||
# later; this is about the image the user is sent.
|
||||
#
|
||||
# Gated on `timelapse_was_active` precisely because that is the
|
||||
# condition under which the producer skipped. On every other path it
|
||||
# has already raised and lowered the plate, and repeating that here
|
||||
# would be a second pointless round trip.
|
||||
if (
|
||||
not photo_filename
|
||||
and data.get("timelapse_was_active")
|
||||
and not print_dispatch_context.end_gcode_injected(printer_id)
|
||||
):
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
restore_setting = await get_setting(db, "finish_photo_restore_plate")
|
||||
if restore_setting is None or restore_setting.lower() == "true":
|
||||
max_z = await _max_z_for_current_print(printer_id, data, logger)
|
||||
if max_z is not None and not await _plate_restore_is_blocked_by_queue(printer_id):
|
||||
if await _restore_plate_for_finish_photo(printer_id, max_z, logger):
|
||||
plate_restored_z = max_z
|
||||
except Exception as e:
|
||||
logger.warning("[PLATE-RESTORE] printer %s: restore failed: %s", printer_id, e)
|
||||
|
||||
# Fallback chain: external camera → buffered live frame →
|
||||
# fresh RTSP capture. Only runs if the timelapse path above
|
||||
# didn't already produce a photo.
|
||||
|
|
@ -5471,6 +5792,15 @@ async def on_print_complete(printer_id: int, data: dict):
|
|||
except Exception as e:
|
||||
logger.warning("[PHOTO-BG] Failed: %s", e)
|
||||
return None
|
||||
finally:
|
||||
# #2547: we raised the plate, so we owe the move back down — even if
|
||||
# the capture in between threw. Otherwise the user finds the print
|
||||
# pinned under the nozzle.
|
||||
if plate_restored_z is not None:
|
||||
try:
|
||||
_park_plate_after_finish_photo(printer_id, plate_restored_z, logger)
|
||||
except Exception as e:
|
||||
logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e)
|
||||
|
||||
spawn_background_task(_background_energy_calculation(), name="background-energy-calc")
|
||||
# Photo capture task - result will be used by notifications
|
||||
|
|
@ -5673,7 +6003,22 @@ async def on_print_complete(printer_id: int, data: dict):
|
|||
# timelapse for up to 60s (#1397) — extend the budget so the notification
|
||||
# carries the correct bed-up photo instead of falling through to the
|
||||
# live-cam grab. Adds ~30s of notification latency at worst on slow links.
|
||||
photo_wait_timeout = 75 if data.get("timelapse_was_active") else 45
|
||||
#
|
||||
# #2547: both budgets now have to cover a plate restore as well.
|
||||
#
|
||||
# Without timelapse, the wait is on the moment producer, which raises the
|
||||
# plate before its grab — so this has to outlast that producer's own budget.
|
||||
#
|
||||
# With timelapse, the capture polls up to
|
||||
# `_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS` for the video and only then
|
||||
# falls back to a live grab, which is the case that raises the plate. At the
|
||||
# old flat 75s that fallback was guaranteed to be cut off mid-settle, so the
|
||||
# restore would have moved the plate for a photo nobody waited for.
|
||||
photo_wait_timeout = (
|
||||
_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + _FINISH_PHOTO_PRODUCER_WAIT_SECONDS
|
||||
if data.get("timelapse_was_active")
|
||||
else _FINISH_PHOTO_PRODUCER_WAIT_SECONDS + 15
|
||||
)
|
||||
|
||||
async def _photo_then_notify():
|
||||
"""Wait for photo capture, then send notification with photo URL."""
|
||||
|
|
@ -6590,10 +6935,10 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
await tl_layer_change(printer_id, layer_num)
|
||||
|
||||
# #1867: bank a recent in-print frame so the FINISH-state finish-photo
|
||||
# path (firmware that never emits stg_cur=22, e.g. A1 Mini) has a
|
||||
# pre-swap image to fall back on instead of a live grab of the swapped
|
||||
# plate. Layer-driven, so it freezes at the final object layer.
|
||||
# #1867: bank a recent in-print frame so the finish-photo path has a
|
||||
# pre-End-G-code image to use instead of a live grab of a swapped plate.
|
||||
# #2547 added `on_print_progress` as a second driver — this one alone
|
||||
# stops firing once the final layer begins.
|
||||
await _maybe_bank_inprint_frame(printer_id, layer_num)
|
||||
|
||||
# First layer complete notification (layer_num >= 2 means layer 1 is done).
|
||||
|
|
@ -6637,6 +6982,21 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
printer_manager.set_layer_change_callback(on_layer_change)
|
||||
|
||||
async def on_print_progress(printer_id: int, percent: int):
|
||||
"""#2547: keep the in-print frame bank fresh through the final layer.
|
||||
|
||||
`on_layer_change` stops the moment the last layer starts, which on the
|
||||
H2C capture that closed #2547 left the bank stale for the three minutes
|
||||
that layer took. Progress is the only field that keeps advancing there,
|
||||
and it freezes before the End G-code runs — so banking on it stays
|
||||
inside the print and never sees a swapped plate.
|
||||
"""
|
||||
client = printer_manager.get_client(printer_id)
|
||||
state = client.state if client else None
|
||||
await _maybe_bank_inprint_frame(printer_id, state.layer_num if state else 0)
|
||||
|
||||
printer_manager.set_print_progress_callback(on_print_progress)
|
||||
|
||||
# Event-driven bed cooldown: fires whenever bed_temper arrives via MQTT
|
||||
async def on_bed_temp_update(printer_id: int, bed_temp: float):
|
||||
waiter = _bed_cool_waiters.get(printer_id)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ class AppSettings(BaseModel):
|
|||
"this print, otherwise it is deleted automatically after the photo is captured."
|
||||
),
|
||||
)
|
||||
finish_photo_restore_plate: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Raise the build plate back into camera framing before taking the finish photo. "
|
||||
"Bambu's end G-code drops the plate ~100mm as the last thing it does, leaving the "
|
||||
"finished print far below the camera's natural framing. Bambuddy moves it back to "
|
||||
"just above the last printed layer, takes the photo, then lowers it again. Skipped "
|
||||
"when the print height is unknown or another job is queued for the printer."
|
||||
),
|
||||
)
|
||||
default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
|
||||
currency: str = Field(default="USD", description="Currency for cost tracking")
|
||||
energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
|
||||
|
|
@ -496,6 +506,7 @@ class AppSettingsUpdate(BaseModel):
|
|||
auto_archive: bool | None = None
|
||||
save_thumbnails: bool | None = None
|
||||
capture_finish_photo: bool | None = None
|
||||
finish_photo_restore_plate: bool | None = None
|
||||
default_filament_cost: float | None = None
|
||||
currency: str | None = None
|
||||
energy_cost_per_kwh: float | None = None
|
||||
|
|
|
|||
|
|
@ -661,6 +661,7 @@ class BambuMQTTClient:
|
|||
on_print_complete: Callable[[dict], None] | None = None,
|
||||
on_ams_change: Callable[[list], None] | None = None,
|
||||
on_layer_change: Callable[[int], None] | None = None,
|
||||
on_print_progress: Callable[[int], None] | None = None,
|
||||
on_bed_temp_update: Callable[[float], None] | None = None,
|
||||
on_drying_complete: Callable[[int], None] | None = None,
|
||||
on_print_running_observed: Callable[[dict], None] | None = None,
|
||||
|
|
@ -678,6 +679,13 @@ class BambuMQTTClient:
|
|||
self.on_print_complete = on_print_complete
|
||||
self.on_ams_change = on_ams_change
|
||||
self.on_layer_change = on_layer_change
|
||||
# #2547: fired when `mc_percent` advances during a running print.
|
||||
# `on_layer_change` stops firing the instant the final layer starts, so
|
||||
# it is blind to the last few percent of a print — which is exactly the
|
||||
# window the finish-photo frame bank needs to keep refreshing through.
|
||||
# Progress is the one field that keeps ticking there and then freezes
|
||||
# before the end G-code runs, so banking on it stays inside the print.
|
||||
self.on_print_progress = on_print_progress
|
||||
self.on_bed_temp_update = on_bed_temp_update
|
||||
# #1349: fired when an AMS unit's dry_time falls from >0 to 0 — i.e.
|
||||
# the drying cycle just finished (auto- or manually-triggered).
|
||||
|
|
@ -2988,7 +2996,14 @@ class BambuMQTTClient:
|
|||
# Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
|
||||
if self.state.progress > 0:
|
||||
self._last_valid_progress = self.state.progress
|
||||
previous_progress = self.state.progress
|
||||
self.state.progress = float(data["mc_percent"])
|
||||
# #2547: strictly-increasing only. The firmware resets progress to 0
|
||||
# on cancel and re-reports the same percent on most frames; neither
|
||||
# is the print advancing, and both would make the frame bank grab a
|
||||
# camera frame for nothing.
|
||||
if self.state.progress > previous_progress and self._was_running and self.on_print_progress:
|
||||
self.on_print_progress(int(self.state.progress))
|
||||
if "mc_remaining_time" in data:
|
||||
self.state.remaining_time = int(data["mc_remaining_time"])
|
||||
if "mc_print_sub_stage" in data:
|
||||
|
|
@ -3069,35 +3084,20 @@ class BambuMQTTClient:
|
|||
new_layer,
|
||||
)
|
||||
self._request_push_all()
|
||||
# #1867 last-layer finish-photo trigger. A1 Mini (and other
|
||||
# firmware variants) skips `stg_cur=22`, so the fallback fires
|
||||
# at gcode_state=FINISH — which runs AFTER user End G-code
|
||||
# (e.g. SwapMod plate-swap) and captures the wrong plate.
|
||||
# Firing on the layer_num→total_layer_num edge captures the
|
||||
# last object layer before any end G-code executes.
|
||||
total = self.state.total_layers or 0
|
||||
if (
|
||||
total > 0
|
||||
and new_layer >= total
|
||||
and old_layer < total
|
||||
and self._was_running
|
||||
and not self._finish_photo_captured
|
||||
and self.on_finish_photo_moment
|
||||
):
|
||||
self._finish_photo_captured = True
|
||||
logger.info(
|
||||
f"[{self.serial_number}] FINISH PHOTO MOMENT (last-layer) — "
|
||||
f"layer={new_layer}/{total}, "
|
||||
f"timelapse_active={self._timelapse_during_print}"
|
||||
)
|
||||
self.on_finish_photo_moment(
|
||||
{
|
||||
"trigger": "last_layer",
|
||||
"filename": self._previous_gcode_file or self.state.gcode_file,
|
||||
"subtask_name": self.state.subtask_name,
|
||||
"timelapse_was_active": self._timelapse_during_print,
|
||||
}
|
||||
)
|
||||
# #2547: there is deliberately NO finish-photo trigger on the
|
||||
# last-layer edge. `layer_num` reaching `total_layer_num` is the
|
||||
# moment the printer *starts* the final layer, not the moment it
|
||||
# finishes it — on the H2C capture that closed #2547 the edge
|
||||
# arrived at 92% with `mc_remaining_time=2`, three minutes and a
|
||||
# filament change before the print actually ended, so the photo
|
||||
# showed the toolhead mid-print over the part. Worse, the trigger
|
||||
# latched `_finish_photo_captured`, locking out both the stage-22
|
||||
# and FINISH triggers below for the rest of the print.
|
||||
#
|
||||
# #1867 (End G-code ejects the plate before FINISH) is handled
|
||||
# where it belongs instead: `on_finish_photo_moment` prefers the
|
||||
# in-print frame bank when the dispatcher recorded that it injected
|
||||
# End G-code into this print. See services/print_dispatch_context.
|
||||
if total_from_this_frame:
|
||||
# Firmware (P1S observed) resets `total_layer_num` to 0 at print
|
||||
# end — same shape as the `layer_num` reset guarded above. Applying
|
||||
|
|
|
|||
68
backend/app/services/print_dispatch_context.py
Normal file
68
backend/app/services/print_dispatch_context.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""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)
|
||||
|
|
@ -23,6 +23,7 @@ from backend.app.models.settings import Settings
|
|||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
||||
from backend.app.services import print_dispatch_context
|
||||
from backend.app.services.bambu_ftp import (
|
||||
UploadCancelled,
|
||||
cache_3mf_download,
|
||||
|
|
@ -3336,6 +3337,10 @@ class PrintScheduler:
|
|||
|
||||
# G-code injection for auto-print systems (#422)
|
||||
injected_path = None
|
||||
# #2547: tracked separately from `injected_path`, which is also set when
|
||||
# only a START snippet was injected. Only an END snippet changes what the
|
||||
# camera sees at print completion.
|
||||
end_gcode_injected = False
|
||||
if item.gcode_injection:
|
||||
try:
|
||||
snippets_raw = await self._get_setting(db, "gcode_snippets")
|
||||
|
|
@ -3352,6 +3357,7 @@ class PrintScheduler:
|
|||
)
|
||||
if injected_path:
|
||||
file_path = injected_path
|
||||
end_gcode_injected = bool(end_gc)
|
||||
logger.info("Queue item %s: G-code injected for model %s", item.id, printer.model)
|
||||
else:
|
||||
logger.warning(
|
||||
|
|
@ -3360,6 +3366,13 @@ class PrintScheduler:
|
|||
except Exception as e:
|
||||
logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
|
||||
|
||||
# #2547: the finish-photo path can't learn from telemetry that this print
|
||||
# ends with user End G-code — which means the plate may be gone by the
|
||||
# time FINISH arrives (#1867). Flag it here; `on_print_start` binds it to
|
||||
# the print once the printer confirms it running.
|
||||
if end_gcode_injected:
|
||||
print_dispatch_context.mark_pending(printer.id)
|
||||
|
||||
# Upload to root directory (not /cache/) - the start_print command references
|
||||
# files by name only (ftp://{filename}), so they must be in the root
|
||||
remote_filename = derive_remote_filename(filename)
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ class PrinterManager:
|
|||
self._on_status_change: Callable[[int, PrinterState], None] | None = None
|
||||
self._on_ams_change: Callable[[int, list], None] | None = None
|
||||
self._on_layer_change: Callable[[int, int], None] | None = None
|
||||
self._on_print_progress: Callable[[int, int], None] | None = None
|
||||
self._on_bed_temp_update: Callable[[int, float], None] | None = None
|
||||
self._on_drying_complete: Callable[[int, int], None] | None = None
|
||||
self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
|
||||
|
|
@ -548,6 +549,15 @@ class PrinterManager:
|
|||
"""Set callback for layer change events. Receives (printer_id, layer_num)."""
|
||||
self._on_layer_change = callback
|
||||
|
||||
def set_print_progress_callback(self, callback: Callable[[int, int], None]):
|
||||
"""Set callback for print-progress advances (#2547).
|
||||
|
||||
Receives (printer_id, percent) each time `mc_percent` increases during a
|
||||
running print — including the final layer, where layer-change events
|
||||
have already stopped.
|
||||
"""
|
||||
self._on_print_progress = callback
|
||||
|
||||
def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
|
||||
"""Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
|
||||
self._on_bed_temp_update = callback
|
||||
|
|
@ -624,6 +634,10 @@ class PrinterManager:
|
|||
if self._on_layer_change:
|
||||
self._schedule_async(self._on_layer_change(printer_id, layer_num))
|
||||
|
||||
def on_print_progress(percent: int):
|
||||
if self._on_print_progress:
|
||||
self._schedule_async(self._on_print_progress(printer_id, percent))
|
||||
|
||||
def on_bed_temp_update(bed_temp: float):
|
||||
if self._on_bed_temp_update:
|
||||
self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
|
||||
|
|
@ -646,6 +660,7 @@ class PrinterManager:
|
|||
on_print_complete=on_print_complete,
|
||||
on_ams_change=on_ams_change,
|
||||
on_layer_change=on_layer_change,
|
||||
on_print_progress=on_print_progress,
|
||||
on_bed_temp_update=on_bed_temp_update,
|
||||
on_drying_complete=on_drying_complete,
|
||||
on_print_running_observed=on_print_running_observed,
|
||||
|
|
|
|||
|
|
@ -702,6 +702,68 @@ def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
|
|||
return header
|
||||
|
||||
|
||||
def _select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
|
||||
"""Pick a plate's ``.gcode`` member out of a 3MF namelist.
|
||||
|
||||
Prefers ``plate_<id>.gcode``, then falls back to the first ``.gcode``
|
||||
member so single-plate files — and files from slicers that don't use the
|
||||
plate naming convention — still resolve.
|
||||
"""
|
||||
gcodes = [n for n in names if n.endswith(".gcode")]
|
||||
if not gcodes:
|
||||
return None
|
||||
if plate_id is not None:
|
||||
suffix = f"plate_{plate_id}.gcode"
|
||||
for name in gcodes:
|
||||
if name.endswith(suffix):
|
||||
return name
|
||||
return gcodes[0]
|
||||
|
||||
|
||||
# The header block sits at the very top of the plate G-code. Read only that
|
||||
# much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
|
||||
# would inflate all of it to reach ~40 lines.
|
||||
_HEADER_READ_LIMIT_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
|
||||
"""Return the plate's ``max_z_height`` in mm, or None if not knowable.
|
||||
|
||||
This is the Z the toolhead sat at for the final layer — the same value
|
||||
Bambu's own end G-code adds its bed-drop offset to (``G1 Z{max_layer_z +
|
||||
100}``). #2547 uses it to put the plate back into camera framing before the
|
||||
finish photo, which is only safe because it is a height the printer was
|
||||
physically at seconds earlier.
|
||||
|
||||
None means "don't know" and callers must treat it as such rather than
|
||||
substituting a default: the file may be unreadable, carry no plate G-code,
|
||||
or come from a slicer that writes no ``max_z_height`` header. Guessing a
|
||||
height here would command a Z move to somewhere the nozzle has never been.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
target = _select_plate_gcode_name(zf.namelist(), plate_id)
|
||||
if target is None:
|
||||
return None
|
||||
with zf.open(target, "r") as fh:
|
||||
head = fh.read(_HEADER_READ_LIMIT_BYTES)
|
||||
except (OSError, zipfile.BadZipFile, KeyError) as e:
|
||||
logger.debug("max_z_height: cannot read %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
raw = _parse_3mf_gcode_header(head.decode("utf-8", errors="ignore")).get("max_z_height")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
logger.debug("max_z_height: unusable value %r in %s", raw, file_path)
|
||||
return None
|
||||
# Zero or negative means the header key is present but meaningless. Passed
|
||||
# on as a height it would become a move *toward* the bed, so drop it.
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
|
||||
"""Replace `{var}` placeholders with header values, leaving unknowns intact."""
|
||||
|
||||
|
|
@ -802,21 +864,10 @@ def inject_gcode_into_3mf(
|
|||
try:
|
||||
# Find the target gcode file inside the 3MF
|
||||
with zipfile.ZipFile(source_path, "r") as zf:
|
||||
all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
|
||||
if not all_gcode:
|
||||
return None
|
||||
|
||||
# Try plate-specific gcode file first
|
||||
target_gcode = None
|
||||
plate_pattern = f"plate_{plate_id}.gcode"
|
||||
for f in all_gcode:
|
||||
if f.endswith(plate_pattern):
|
||||
target_gcode = f
|
||||
break
|
||||
|
||||
# Fall back to first gcode file
|
||||
# Plate-specific gcode first, else the first one in the file.
|
||||
target_gcode = _select_plate_gcode_name(zf.namelist(), plate_id)
|
||||
if target_gcode is None:
|
||||
target_gcode = all_gcode[0]
|
||||
return None
|
||||
|
||||
# Read and modify gcode content
|
||||
gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
|
||||
|
|
|
|||
|
|
@ -6327,14 +6327,19 @@ class TestTrayNowH2SExternalSpoolOverride:
|
|||
assert mqtt_client.state.tray_now == 255
|
||||
|
||||
|
||||
class TestLastLayerFinishPhotoTrigger:
|
||||
"""Tests for #1867: layer_num→total_layer_num edge fires the finish-photo
|
||||
moment before user End G-code (e.g. SwapMod) executes.
|
||||
class TestNoLastLayerFinishPhotoTrigger:
|
||||
"""#2547: the layer_num→total_layer_num edge must NOT trigger a photo.
|
||||
|
||||
A1 Mini firmware skips stg_cur=22 entirely, so the FINISH-state fallback
|
||||
fires after end G-code has already moved the plate. The last-layer edge
|
||||
is the earliest reliable "print finished" signal available across all
|
||||
Bambu printer variants.
|
||||
That edge is the moment the printer *starts* the final layer. On the H2C
|
||||
capture that closed #2547 it arrived at 92% with `mc_remaining_time=2`,
|
||||
three minutes and one filament change before the print actually ended, so
|
||||
the photo showed the toolhead mid-print over the part. It also latched
|
||||
`_finish_photo_captured`, which locked out the two triggers that fire at a
|
||||
real end-of-print — so these tests pin both halves: the edge is silent, and
|
||||
the later triggers still work after it has passed.
|
||||
|
||||
#1867 (End G-code ejecting the plate before FINISH) is handled in
|
||||
`on_finish_photo_moment` via `print_dispatch_context`, not here.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -6351,79 +6356,31 @@ class TestLastLayerFinishPhotoTrigger:
|
|||
client.state.layer_num = 99
|
||||
return client
|
||||
|
||||
def test_fires_when_layer_reaches_total(self, mqtt_client):
|
||||
def test_reaching_the_last_layer_fires_nothing(self, mqtt_client):
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["trigger"] == "last_layer"
|
||||
assert mqtt_client._finish_photo_captured is True
|
||||
|
||||
def test_does_not_fire_when_layer_still_below_total(self, mqtt_client):
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 99}})
|
||||
|
||||
assert events == []
|
||||
assert mqtt_client._finish_photo_captured is False
|
||||
|
||||
def test_edge_only_no_double_fire(self, mqtt_client):
|
||||
"""Once fired, subsequent messages at layer_num == total must not
|
||||
re-fire (the guard flips _finish_photo_captured to True)."""
|
||||
def test_stage_22_still_fires_after_the_last_layer_started(self, mqtt_client):
|
||||
"""The regression the removed trigger caused: stage 22 is the good
|
||||
moment on firmware that emits it, and it arrives *after* the last-layer
|
||||
edge. The old latch swallowed it."""
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
|
||||
assert len(events) == 1
|
||||
|
||||
def test_does_not_fire_when_not_running(self, mqtt_client):
|
||||
"""If the print never went through RUNNING (Bambuddy restart mid-print,
|
||||
firmware replay), _was_running is False and no photo trigger fires."""
|
||||
mqtt_client._was_running = False
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
|
||||
assert events == []
|
||||
|
||||
def test_does_not_fire_when_total_layers_unknown(self, mqtt_client):
|
||||
"""total=0 (before slicer metadata arrives) must never satisfy the
|
||||
`new_layer >= total` condition."""
|
||||
mqtt_client.state.total_layers = 0
|
||||
mqtt_client.state.layer_num = 0
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 0}})
|
||||
|
||||
assert events == []
|
||||
|
||||
def test_stage_22_skipped_after_last_layer_already_fired(self, mqtt_client):
|
||||
"""Once the last-layer trigger has set _finish_photo_captured, the
|
||||
stage-22 hook that runs later on AMS printers must be a no-op."""
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
assert len(events) == 1
|
||||
|
||||
mqtt_client.state.progress = 100
|
||||
mqtt_client._process_message({"print": {"stg_cur": 22}})
|
||||
|
||||
assert len(events) == 1
|
||||
assert [e["trigger"] for e in events] == ["stage_22"]
|
||||
|
||||
def test_finish_state_fallback_skipped_after_last_layer_fired(self, mqtt_client):
|
||||
"""The gcode_state=FINISH fallback (which fires after end G-code on
|
||||
every printer) must be suppressed once the last-layer edge fired.
|
||||
This is the #1867 regression check — SwapMod plate must be captured
|
||||
by last_layer, NOT by the post-End-G-code FINISH fallback."""
|
||||
def test_finish_state_still_fires_after_the_last_layer_started(self, mqtt_client):
|
||||
"""H2C/A1 Mini never emit stage 22, so FINISH is their only moment —
|
||||
and it is now reachable, where the latch used to block it."""
|
||||
events = []
|
||||
completion_events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
|
@ -6431,13 +6388,92 @@ class TestLastLayerFinishPhotoTrigger:
|
|||
mqtt_client._previous_gcode_state = "RUNNING"
|
||||
|
||||
mqtt_client._process_message({"print": {"layer_num": 100}})
|
||||
assert events[0]["trigger"] == "last_layer"
|
||||
|
||||
mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
||||
|
||||
assert len(events) == 1
|
||||
assert [e["trigger"] for e in events] == ["finish_state"]
|
||||
assert len(completion_events) == 1
|
||||
|
||||
def test_no_photo_trigger_fires_repeatedly_across_the_last_layer(self, mqtt_client):
|
||||
"""A three-minute last layer publishes many frames at layer_num ==
|
||||
total. None of them may produce a moment."""
|
||||
events = []
|
||||
mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
|
||||
|
||||
for percent in (92, 93, 94, 95, 97, 98, 99):
|
||||
mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": percent}})
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
class TestPrintProgressCallback:
|
||||
"""#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
|
||||
|
||||
Layer changes stop firing the instant the final layer begins, so the bank
|
||||
would otherwise stay stale for the whole length of that layer. Progress is
|
||||
the field that keeps advancing there — and it freezes before the End G-code
|
||||
runs, which is what keeps a swapped plate out of the bank (#1867).
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TEST123",
|
||||
access_code="12345678",
|
||||
)
|
||||
client._was_running = True
|
||||
return client
|
||||
|
||||
def test_fires_on_each_advance(self, mqtt_client):
|
||||
seen = []
|
||||
mqtt_client.on_print_progress = seen.append
|
||||
|
||||
for percent in (92, 93, 94):
|
||||
mqtt_client._process_message({"print": {"mc_percent": percent}})
|
||||
|
||||
assert seen == [92, 93, 94]
|
||||
|
||||
def test_does_not_fire_when_progress_is_unchanged(self, mqtt_client):
|
||||
"""Most frames repeat the same percent; each one would otherwise cost a
|
||||
camera grab that contends with the live view."""
|
||||
seen = []
|
||||
mqtt_client.on_print_progress = seen.append
|
||||
|
||||
mqtt_client._process_message({"print": {"mc_percent": 92}})
|
||||
mqtt_client._process_message({"print": {"mc_percent": 92}})
|
||||
mqtt_client._process_message({"print": {"mc_percent": 92}})
|
||||
|
||||
assert seen == [92]
|
||||
|
||||
def test_does_not_fire_when_progress_goes_backwards(self, mqtt_client):
|
||||
"""Firmware resets progress to 0 on cancel — that is not the print
|
||||
advancing, and banking a frame there would be banking a cancelled bed."""
|
||||
seen = []
|
||||
mqtt_client.on_print_progress = seen.append
|
||||
|
||||
mqtt_client._process_message({"print": {"mc_percent": 92}})
|
||||
mqtt_client._process_message({"print": {"mc_percent": 0}})
|
||||
|
||||
assert seen == [92]
|
||||
|
||||
def test_does_not_fire_when_no_print_is_running(self, mqtt_client):
|
||||
mqtt_client._was_running = False
|
||||
seen = []
|
||||
mqtt_client.on_print_progress = seen.append
|
||||
|
||||
mqtt_client._process_message({"print": {"mc_percent": 92}})
|
||||
|
||||
assert seen == []
|
||||
|
||||
def test_absent_callback_is_not_an_error(self, mqtt_client):
|
||||
mqtt_client.on_print_progress = None
|
||||
|
||||
mqtt_client._process_message({"print": {"mc_percent": 92}})
|
||||
|
||||
assert mqtt_client.state.progress == 92
|
||||
|
||||
|
||||
class TestPresumedPowerOffRecovery:
|
||||
"""#2629: a smart-plug turn-off marks the printer offline optimistically.
|
||||
|
|
|
|||
77
backend/tests/unit/services/test_print_dispatch_context.py
Normal file
77
backend/tests/unit/services/test_print_dispatch_context.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""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
|
||||
|
|
@ -15,6 +15,7 @@ by the consumer. These tests pin the producer side of that contract.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
|
@ -23,6 +24,7 @@ import pytest
|
|||
|
||||
from backend.app import main as main_module
|
||||
from backend.app.main import on_finish_photo_moment
|
||||
from backend.app.services import print_dispatch_context
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -54,11 +56,13 @@ def _clean_state():
|
|||
main_module._stage22_finish_frames.clear()
|
||||
main_module._inprint_frame_bank.clear()
|
||||
main_module._inprint_frame_bank_ts.clear()
|
||||
print_dispatch_context.clear(7)
|
||||
yield
|
||||
main_module._stage22_finish_in_flight.clear()
|
||||
main_module._stage22_finish_frames.clear()
|
||||
main_module._inprint_frame_bank.clear()
|
||||
main_module._inprint_frame_bank_ts.clear()
|
||||
print_dispatch_context.clear(7)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -78,6 +82,18 @@ def patched_env(fake_printer, monkeypatch):
|
|||
"backend.app.api.routes.camera.get_buffered_frame",
|
||||
lambda _pid: None,
|
||||
)
|
||||
|
||||
# #2547: default the plate restore to "print height unknown", so tests that
|
||||
# aren't about the restore never reach the G-code path. Tests that ARE about
|
||||
# it override these two.
|
||||
async def _no_height(_printer_id, _data, _logger):
|
||||
return None
|
||||
|
||||
async def _not_blocked(_printer_id):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main_module, "_max_z_for_current_print", _no_height)
|
||||
monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _not_blocked)
|
||||
return fake_printer
|
||||
|
||||
|
||||
|
|
@ -212,10 +228,12 @@ async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monk
|
|||
await producer
|
||||
|
||||
|
||||
async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch):
|
||||
"""#1867: on the FINISH-state fallback (stage-22-less firmware, e.g. A1
|
||||
Mini) a live grab captures the post-swap plate. When a banked in-print
|
||||
frame exists it must be used instead, and the live grab must not run."""
|
||||
async def test_finish_state_prefers_banked_frame_when_end_gcode_was_injected(patched_env, monkeypatch):
|
||||
"""#1867: when Bambuddy injected End G-code, a SwapMod snippet may already
|
||||
have ejected the plate by FINISH — so the banked in-print frame is used and
|
||||
the live grab must not run."""
|
||||
print_dispatch_context.mark_pending(patched_env.id)
|
||||
print_dispatch_context.adopt(patched_env.id)
|
||||
main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
|
||||
|
||||
live_called = {"n": 0}
|
||||
|
|
@ -232,10 +250,31 @@ async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch):
|
|||
assert live_called["n"] == 0
|
||||
|
||||
|
||||
async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeypatch):
|
||||
"""No banked frame (feature just enabled, tiny print, capture failures) —
|
||||
the FINISH-state path still live-grabs so we degrade to the old behaviour
|
||||
rather than sending a text-only notification."""
|
||||
async def test_finish_state_grabs_live_when_no_end_gcode_was_injected(patched_env, monkeypatch):
|
||||
"""#2547: the ordinary case. Nothing moved the plate, the toolhead is
|
||||
parked, and the print is still sitting there — so the live frame is the
|
||||
finished print, and a banked mid-print frame must NOT win over it.
|
||||
|
||||
Preferring the bank here unconditionally, which is what this code used to
|
||||
do, is how the H2C shipped a photo with the toolhead over the part."""
|
||||
main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked-midprint"
|
||||
|
||||
async def _live(**_kwargs):
|
||||
return b"\xff\xd8live-finished-print"
|
||||
|
||||
monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live-finished-print"
|
||||
|
||||
|
||||
async def test_finish_state_falls_back_to_live_when_bank_is_empty(patched_env, monkeypatch):
|
||||
"""End G-code was injected but nothing was ever banked (feature just
|
||||
enabled, tiny print, capture failures). Degrade to a live grab rather than
|
||||
sending a text-only notification."""
|
||||
print_dispatch_context.mark_pending(patched_env.id)
|
||||
print_dispatch_context.adopt(patched_env.id)
|
||||
|
||||
async def _live(**_kwargs):
|
||||
return b"\xff\xd8live"
|
||||
|
|
@ -247,10 +286,12 @@ async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeyp
|
|||
assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
|
||||
|
||||
|
||||
async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch):
|
||||
"""The `last_layer` trigger fires before the swap and gives cleaner
|
||||
(parked-toolhead) framing via a live grab — the bank is only for the
|
||||
post-swap `finish_state` fallback, so it must be ignored here."""
|
||||
async def test_stage_22_trigger_ignores_bank(patched_env, monkeypatch):
|
||||
"""The `stage_22` trigger fires before any End G-code and gives cleaner
|
||||
(parked-toolhead, plate-still-up) framing via a live grab — the bank is only
|
||||
for the post-swap `finish_state` path, so it must be ignored here."""
|
||||
print_dispatch_context.mark_pending(patched_env.id)
|
||||
print_dispatch_context.adopt(patched_env.id)
|
||||
main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
|
||||
|
||||
async def _live(**_kwargs):
|
||||
|
|
@ -258,7 +299,7 @@ async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch):
|
|||
|
||||
monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "last_layer"})
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
|
||||
|
||||
assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
|
||||
|
||||
|
|
@ -299,10 +340,25 @@ async def test_bank_throttles_within_interval(monkeypatch):
|
|||
assert main_module._inprint_frame_bank[3] == b"frame-1"
|
||||
|
||||
|
||||
async def test_bank_always_refreshes_on_last_layer(monkeypatch):
|
||||
async def test_bank_throttles_on_the_last_layer_too(monkeypatch):
|
||||
"""#2547: the last layer used to bypass the throttle so it always got a
|
||||
fresh frame. Now that progress advances also drive banking, that exemption
|
||||
would fire a camera grab on every percent tick of a multi-minute last layer
|
||||
— and each grab contends with the live view for the single RTSP slot."""
|
||||
counter = _bank_env(monkeypatch, total_layers=10)
|
||||
await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1
|
||||
# Last layer bypasses the throttle for the best final framing.
|
||||
await main_module._maybe_bank_inprint_frame(3, 10) # last layer, within 25s
|
||||
assert counter["n"] == 1
|
||||
assert main_module._inprint_frame_bank[3] == b"frame-1"
|
||||
|
||||
|
||||
async def test_bank_refreshes_on_the_last_layer_once_the_throttle_elapses(monkeypatch):
|
||||
"""The point of banking on progress: a three-minute last layer keeps
|
||||
refreshing instead of freezing at the moment that layer began."""
|
||||
counter = _bank_env(monkeypatch, total_layers=10)
|
||||
await main_module._maybe_bank_inprint_frame(3, 10) # banks frame-1
|
||||
# Pretend the throttle window has passed, as it does mid-last-layer.
|
||||
main_module._inprint_frame_bank_ts[3] -= main_module._INPRINT_BANK_MIN_INTERVAL + 1
|
||||
await main_module._maybe_bank_inprint_frame(3, 10)
|
||||
assert counter["n"] == 2
|
||||
assert main_module._inprint_frame_bank[3] == b"frame-2"
|
||||
|
|
@ -380,6 +436,9 @@ class TestStage22CacheHoldsExactlyOneRotation:
|
|||
does).
|
||||
"""
|
||||
monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
|
||||
# #2547: the bank is only preferred when End G-code was injected.
|
||||
print_dispatch_context.mark_pending(patched_env.id)
|
||||
print_dispatch_context.adopt(patched_env.id)
|
||||
already_rotated = self._jpeg(32, 64) # what one rotation of a 64x32 frame looks like
|
||||
main_module._inprint_frame_bank[patched_env.id] = already_rotated
|
||||
|
||||
|
|
@ -458,3 +517,359 @@ def test_the_consumer_does_not_rotate_the_cached_frame():
|
|||
"Those bytes are already rotated by on_finish_photo_moment (#2708); rotating "
|
||||
"again returns a 180-degree print to upside-down."
|
||||
)
|
||||
|
||||
|
||||
class TestPlateRestore:
|
||||
"""#2547 / #1145 / #1397 / #1565: put the plate back into camera framing.
|
||||
|
||||
Bambu's end G-code drops the plate ~100mm as the last thing it does, so by
|
||||
FINISH the finished print sits well below where the camera frames it. The
|
||||
restore commands an ABSOLUTE Z back to just above the last printed layer.
|
||||
|
||||
Absolute is the safety argument, and these tests pin it: the target is a
|
||||
height the toolhead was physically at seconds earlier, so it is inside the
|
||||
travel limits and leaves the nozzle above the part. It is also unambiguous
|
||||
across model families — Z is the nozzle-to-bed gap whether the bed moves or
|
||||
the toolhead does — so there is no sign to get wrong the way the relative
|
||||
bed-jog path had (#1334).
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def printer_client(self, monkeypatch):
|
||||
sent: list[str] = []
|
||||
client = SimpleNamespace(
|
||||
state=SimpleNamespace(state="FINISH"),
|
||||
send_gcode=lambda gcode: (sent.append(gcode), True)[1],
|
||||
)
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
|
||||
monkeypatch.setattr(main_module.asyncio, "sleep", AsyncMock())
|
||||
client.sent = sent
|
||||
return client
|
||||
|
||||
async def test_commands_an_absolute_move_above_the_print(self, printer_client):
|
||||
ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert ok is True
|
||||
assert printer_client.sent == ["G90\nG1 Z26.00 F600"]
|
||||
|
||||
async def test_never_touches_m211(self, printer_client):
|
||||
"""#2579: disabling soft endstops is what let a jog drive the nozzle
|
||||
into the bed. This path must not reintroduce it."""
|
||||
await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert not any("M211" in line for line in printer_client.sent)
|
||||
|
||||
async def test_skipped_when_the_printer_is_no_longer_in_finish(self, printer_client):
|
||||
"""The queue dispatches the next job the instant a print completes.
|
||||
Commanding a plate move into a starting print is not a race worth
|
||||
having, so state is re-read immediately before the move."""
|
||||
printer_client.state.state = "RUNNING"
|
||||
|
||||
ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert ok is False
|
||||
assert printer_client.sent == []
|
||||
|
||||
async def test_skipped_when_the_printer_is_gone(self, monkeypatch):
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: None)
|
||||
|
||||
ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert ok is False
|
||||
|
||||
async def test_reports_failure_when_the_send_fails(self, printer_client, monkeypatch):
|
||||
"""A failed send means the plate never moved — the caller must not go on
|
||||
to owe it a move back down."""
|
||||
monkeypatch.setattr(printer_client, "send_gcode", lambda _g: False)
|
||||
|
||||
ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert ok is False
|
||||
|
||||
def test_park_lowers_the_plate_again(self, printer_client):
|
||||
main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert printer_client.sent == ["G90\nG1 Z116.00 F600"]
|
||||
|
||||
def test_park_skipped_once_the_next_print_has_started(self, printer_client):
|
||||
printer_client.state.state = "RUNNING"
|
||||
|
||||
main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
|
||||
|
||||
assert printer_client.sent == []
|
||||
|
||||
|
||||
class TestPlateRestoreWiring:
|
||||
"""The restore only runs in the one situation it is correct for, and the
|
||||
plate always comes back down afterwards."""
|
||||
|
||||
@pytest.fixture
|
||||
def restore_env(self, patched_env, monkeypatch):
|
||||
calls = {"restore": [], "park": [], "blocked": False, "height": 16.0}
|
||||
|
||||
async def _height(_printer_id, _data, _logger):
|
||||
return calls["height"]
|
||||
|
||||
async def _blocked(_printer_id):
|
||||
return calls["blocked"]
|
||||
|
||||
async def _restore(printer_id, max_z, _logger):
|
||||
calls["restore"].append((printer_id, max_z))
|
||||
return True
|
||||
|
||||
def _park(printer_id, max_z, _logger):
|
||||
calls["park"].append((printer_id, max_z))
|
||||
|
||||
monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
|
||||
monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
|
||||
monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
|
||||
monkeypatch.setattr(main_module, "_park_plate_after_finish_photo", _park)
|
||||
|
||||
async def _live(**_kwargs):
|
||||
return b"\xff\xd8live"
|
||||
|
||||
monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
|
||||
return calls
|
||||
|
||||
async def test_restores_then_parks_on_the_finish_state_path(self, patched_env, restore_env):
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == [(patched_env.id, 16.0)]
|
||||
assert restore_env["park"] == [(patched_env.id, 16.0)]
|
||||
|
||||
async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env):
|
||||
"""Stage 22 fires before the end G-code drops the plate — it is already
|
||||
where we want it, and moving it would only cost the settle delay."""
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
|
||||
|
||||
assert restore_env["restore"] == []
|
||||
assert restore_env["park"] == []
|
||||
|
||||
async def test_not_restored_when_the_banked_frame_is_used(self, patched_env, restore_env):
|
||||
"""The plate has been swapped out — no move brings the print back."""
|
||||
print_dispatch_context.mark_pending(patched_env.id)
|
||||
print_dispatch_context.adopt(patched_env.id)
|
||||
main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == []
|
||||
assert restore_env["park"] == []
|
||||
|
||||
async def test_not_restored_when_end_gcode_was_injected_but_the_bank_is_empty(self, patched_env, restore_env):
|
||||
"""A plate-swap machine may have just ejected its plate. Even with no
|
||||
banked frame to fall back on, driving Z into whatever a swap mechanism
|
||||
is doing is not worth a photo of a bed we know may be bare."""
|
||||
print_dispatch_context.mark_pending(patched_env.id)
|
||||
print_dispatch_context.adopt(patched_env.id)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == []
|
||||
assert restore_env["park"] == []
|
||||
|
||||
async def test_not_restored_when_the_print_height_is_unknown(self, patched_env, restore_env):
|
||||
restore_env["height"] = None
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == []
|
||||
assert restore_env["park"] == []
|
||||
|
||||
async def test_not_restored_when_another_job_is_queued(self, patched_env, restore_env):
|
||||
restore_env["blocked"] = True
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == []
|
||||
assert restore_env["park"] == []
|
||||
|
||||
async def test_not_restored_when_the_setting_is_off(self, patched_env, restore_env, monkeypatch):
|
||||
async def _get_setting(_db, key):
|
||||
if key == "capture_finish_photo":
|
||||
return "true"
|
||||
if key == "finish_photo_restore_plate":
|
||||
return "false"
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == []
|
||||
assert restore_env["park"] == []
|
||||
|
||||
async def test_plate_is_parked_even_when_the_capture_throws(self, patched_env, restore_env, monkeypatch):
|
||||
"""We raised it, so we owe the move back down — including when the grab
|
||||
between the two fails. Otherwise the user finds the print pinned under
|
||||
the nozzle."""
|
||||
|
||||
async def _boom(**_kwargs):
|
||||
raise RuntimeError("camera gone")
|
||||
|
||||
monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _boom)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert restore_env["restore"] == [(patched_env.id, 16.0)]
|
||||
assert restore_env["park"] == [(patched_env.id, 16.0)]
|
||||
|
||||
async def test_producer_event_is_still_set_after_a_restore(self, patched_env, restore_env):
|
||||
"""#1790: the consumer's bounded wait must be released on every exit,
|
||||
and the restore added a new path through the producer."""
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert main_module._stage22_finish_in_flight[patched_env.id].is_set()
|
||||
|
||||
|
||||
async def test_producer_wait_budget_covers_the_restore():
|
||||
"""The consumer's wait has to outlast settle + a worst-case RTSP grab, and
|
||||
still finish inside the notification's own photo budget — otherwise the
|
||||
restore path is cut off by a timeout somewhere above it."""
|
||||
assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
|
||||
|
||||
|
||||
class TestMaxZResolution:
|
||||
"""#2547 safety: the height that becomes a Z-move target must provably
|
||||
belong to the print that just finished.
|
||||
|
||||
A height from another print is the one failure mode that could drive the
|
||||
nozzle into the model — 20mm carried onto a 200mm print commands the plate
|
||||
up through the part. So the resolver refuses on every ambiguity rather than
|
||||
falling back to "whatever ran last on this printer".
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _archive(**overrides):
|
||||
base = {
|
||||
"id": 11,
|
||||
"file_path": "/data/archive/1/job/job.3mf",
|
||||
"plate_id": 1,
|
||||
"total_layers": 30,
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
@pytest.fixture
|
||||
def resolver_env(self, monkeypatch):
|
||||
env = {"archive": self._archive(), "reported_layers": 30, "height": 16.0, "where": None}
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session():
|
||||
async def _execute(stmt):
|
||||
env["where"] = str(stmt)
|
||||
return SimpleNamespace(scalar_one_or_none=lambda: env["archive"])
|
||||
|
||||
yield SimpleNamespace(execute=_execute)
|
||||
|
||||
monkeypatch.setattr(main_module, "async_session", _session)
|
||||
monkeypatch.setattr(
|
||||
main_module.printer_manager,
|
||||
"get_client",
|
||||
lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.app.utils.threemf_tools.extract_max_z_height_from_3mf",
|
||||
lambda _path, _plate: env["height"],
|
||||
)
|
||||
return env
|
||||
|
||||
async def test_returns_the_height_when_name_and_layers_agree(self, resolver_env):
|
||||
height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
|
||||
assert height == 16.0
|
||||
|
||||
async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env):
|
||||
"""Without an identifier there is nothing to bind the archive to, and
|
||||
the query would degrade to 'the newest row for this printer'."""
|
||||
height = await main_module._max_z_for_current_print(1, {}, logging.getLogger(__name__))
|
||||
|
||||
assert height is None
|
||||
assert resolver_env["where"] is None # refused before touching the DB
|
||||
|
||||
async def test_refuses_when_no_archive_matches_the_name(self, resolver_env):
|
||||
resolver_env["archive"] = None
|
||||
|
||||
height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
|
||||
assert height is None
|
||||
|
||||
async def test_refuses_when_the_layer_counts_disagree(self, resolver_env):
|
||||
"""The corroboration check. The archive's layer count comes from the
|
||||
3MF; the printer's comes from MQTT. If two independent sources disagree,
|
||||
the row is not this print whatever its name says."""
|
||||
resolver_env["reported_layers"] = 240
|
||||
|
||||
height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
|
||||
assert height is None
|
||||
|
||||
async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env):
|
||||
"""Absent is not the same as contradictory — a print Bambuddy has no
|
||||
layer count for still gets its height, because the name matched."""
|
||||
resolver_env["reported_layers"] = 0
|
||||
assert (
|
||||
await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0
|
||||
)
|
||||
|
||||
resolver_env["reported_layers"] = 30
|
||||
resolver_env["archive"] = self._archive(total_layers=None)
|
||||
assert (
|
||||
await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0
|
||||
)
|
||||
|
||||
async def test_matches_by_equality_not_substring(self, resolver_env):
|
||||
"""`LIKE %name%` would let "Cube" resolve to "Cube v2" — a different
|
||||
print, quite possibly a much taller one."""
|
||||
await main_module._max_z_for_current_print(1, {"subtask_name": "Cube"}, logging.getLogger(__name__))
|
||||
|
||||
assert "LIKE" not in resolver_env["where"].upper()
|
||||
|
||||
async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
|
||||
resolver_env["archive"] = self._archive(file_path=None)
|
||||
|
||||
height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
|
||||
assert height is None
|
||||
|
||||
async def test_refuses_when_the_3mf_has_no_height(self, resolver_env):
|
||||
resolver_env["height"] = None
|
||||
|
||||
height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
|
||||
assert height is None
|
||||
|
||||
|
||||
class TestTimelapsePathPlateRestore:
|
||||
"""#2547: the timelapse path falls through to a live grab whenever the
|
||||
video hasn't landed yet — the documented usual outcome on P1-series.
|
||||
|
||||
`on_finish_photo_moment` returns early for those prints without raising the
|
||||
plate, so the photo that actually ships in the notification would be of an
|
||||
already-dropped plate. The consumer therefore does the restore itself, but
|
||||
only on that path — everywhere else the producer has already done it.
|
||||
"""
|
||||
|
||||
def test_notification_budget_outlasts_the_video_poll_plus_a_restore(self):
|
||||
"""The wait has to cover polling for the video AND the restore that
|
||||
follows when it doesn't arrive. At the old flat 75s the fallback was
|
||||
cut off mid-settle, so the plate would have moved for a photo nobody
|
||||
was still waiting for."""
|
||||
assert (
|
||||
main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
|
||||
> main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
|
||||
)
|
||||
|
||||
async def test_producer_skips_the_restore_when_a_timelapse_was_recording(self, patched_env, monkeypatch):
|
||||
"""The producer returns before any of the restore code — the consumer
|
||||
owns it on this path, and doing it in both would move the plate twice."""
|
||||
moved = []
|
||||
|
||||
async def _restore(printer_id, max_z, _logger):
|
||||
moved.append((printer_id, max_z))
|
||||
return True
|
||||
|
||||
async def _height(_printer_id, _data, _logger):
|
||||
return 16.0
|
||||
|
||||
monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
|
||||
monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
|
||||
|
||||
assert moved == []
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from backend.app.utils.threemf_tools import (
|
|||
extract_bed_type_from_3mf,
|
||||
extract_embedded_presets_from_3mf,
|
||||
extract_filament_usage_from_3mf,
|
||||
extract_max_z_height_from_3mf,
|
||||
extract_plate_extruder_set_from_3mf,
|
||||
extract_print_time_from_3mf,
|
||||
extract_project_filaments_from_3mf,
|
||||
|
|
@ -1304,3 +1305,90 @@ class TestExtractPlateMetadataFrom3mf:
|
|||
assert meta.filament_usage == []
|
||||
# Missing file must not create a sticky cache entry (it may appear later).
|
||||
assert spy.call_count == 2
|
||||
|
||||
|
||||
def _make_plate_3mf(tmp_path, gcode_by_name: dict[str, str], name: str = "print.3mf"):
|
||||
"""Write a 3MF containing the given plate G-code members."""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
for member, content in gcode_by_name.items():
|
||||
zf.writestr(member, content)
|
||||
buffer.seek(0)
|
||||
path = tmp_path / name
|
||||
path.write_bytes(buffer.read())
|
||||
return path
|
||||
|
||||
|
||||
def _header(**values: str) -> str:
|
||||
lines = ["; HEADER_BLOCK_START"]
|
||||
lines += [f"; {key.replace('_', ' ')}: {value}" for key, value in values.items()]
|
||||
lines.append("; HEADER_BLOCK_END")
|
||||
lines.append("G1 X0 Y0")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestExtractMaxZHeightFrom3mf:
|
||||
"""#2547: the print's top Z, used to command the plate back into camera
|
||||
framing before the finish photo.
|
||||
|
||||
This value becomes the target of a real Z move, so "don't know" has to be
|
||||
reported as None rather than defaulted — a wrong height would drive the
|
||||
nozzle into the part.
|
||||
"""
|
||||
|
||||
def test_reads_max_z_height_from_the_plate_header(self, tmp_path):
|
||||
path = _make_plate_3mf(
|
||||
tmp_path,
|
||||
{"Metadata/plate_1.gcode": _header(max_z_height="16.00", total_layer_number="80")},
|
||||
)
|
||||
assert extract_max_z_height_from_3mf(path, 1) == 16.0
|
||||
|
||||
def test_picks_the_requested_plate(self, tmp_path):
|
||||
path = _make_plate_3mf(
|
||||
tmp_path,
|
||||
{
|
||||
"Metadata/plate_1.gcode": _header(max_z_height="16.00"),
|
||||
"Metadata/plate_2.gcode": _header(max_z_height="42.50"),
|
||||
},
|
||||
)
|
||||
assert extract_max_z_height_from_3mf(path, 2) == 42.5
|
||||
|
||||
def test_falls_back_to_the_only_gcode_when_the_plate_name_does_not_match(self, tmp_path):
|
||||
"""Files from slicers that don't use Bambu's plate naming still resolve."""
|
||||
path = _make_plate_3mf(tmp_path, {"whatever.gcode": _header(max_z_height="7.25")})
|
||||
assert extract_max_z_height_from_3mf(path, 3) == 7.25
|
||||
|
||||
def test_missing_header_key_returns_none(self, tmp_path):
|
||||
path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(total_layer_number="80")})
|
||||
assert extract_max_z_height_from_3mf(path, 1) is None
|
||||
|
||||
def test_non_numeric_value_returns_none(self, tmp_path):
|
||||
path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="tall")})
|
||||
assert extract_max_z_height_from_3mf(path, 1) is None
|
||||
|
||||
def test_zero_and_negative_are_treated_as_unknown(self, tmp_path):
|
||||
"""Passed through, either would become a Z move toward the bed."""
|
||||
zero = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="0")}, "z.3mf")
|
||||
negative = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="-3")}, "n.3mf")
|
||||
assert extract_max_z_height_from_3mf(zero, 1) is None
|
||||
assert extract_max_z_height_from_3mf(negative, 1) is None
|
||||
|
||||
def test_no_gcode_member_returns_none(self, tmp_path):
|
||||
path = _make_plate_3mf(tmp_path, {"Metadata/slice_info.config": "<config/>"})
|
||||
assert extract_max_z_height_from_3mf(path, 1) is None
|
||||
|
||||
def test_unreadable_file_returns_none(self, tmp_path):
|
||||
path = tmp_path / "broken.3mf"
|
||||
path.write_text("not a zip")
|
||||
assert extract_max_z_height_from_3mf(path, 1) is None
|
||||
|
||||
def test_missing_file_returns_none(self, tmp_path):
|
||||
assert extract_max_z_height_from_3mf(tmp_path / "nope.3mf", 1) is None
|
||||
|
||||
def test_only_the_header_is_inflated(self, tmp_path):
|
||||
"""A sliced plate is routinely tens of MB; reading it whole to reach ~40
|
||||
header lines would stall the finish-photo path. The header is read from
|
||||
a bounded prefix, so a huge body must not change the answer."""
|
||||
gcode = _header(max_z_height="99.9") + "\n" + ("G1 X1 Y1 E0.1\n" * 400_000)
|
||||
path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": gcode})
|
||||
assert extract_max_z_height_from_3mf(path, 1) == 99.9
|
||||
|
|
|
|||
|
|
@ -110,6 +110,66 @@ describe('SettingsPage', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('finish photo plate restore (#2547)', () => {
|
||||
const restoreLabel = 'Restore plate for finish photo';
|
||||
|
||||
it('offers the toggle while finish photos are enabled', async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the toggle when finish photos are switched off', async () => {
|
||||
// It only describes how the finish photo is framed, so it is meaningless
|
||||
// when no finish photo is taken at all.
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () =>
|
||||
HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
|
||||
)
|
||||
);
|
||||
render(<SettingsPage />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Settings' });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to on when the backend has never stored the setting', async () => {
|
||||
// Existing installs have no row for it; the UI must not read that as off.
|
||||
render(<SettingsPage />);
|
||||
|
||||
const label = await screen.findByText(restoreLabel);
|
||||
const row = label.closest('div')!.parentElement!;
|
||||
expect(within(row).getByRole('checkbox')).toBeChecked();
|
||||
});
|
||||
|
||||
it('sends the new value on save', async () => {
|
||||
let saved: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/v1/settings/', async ({ request }) => {
|
||||
saved = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ ...mockSettings, ...saved });
|
||||
})
|
||||
);
|
||||
render(<SettingsPage />);
|
||||
|
||||
const label = await screen.findByText(restoreLabel);
|
||||
// The page suppresses auto-save for 100ms after the settings load, so a
|
||||
// click landing inside that window is swallowed with no re-trigger.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const row = label.closest('div')!.parentElement!;
|
||||
await userEvent.click(within(row).getByRole('checkbox'));
|
||||
|
||||
// The page auto-saves on a 500ms debounce, so the default 1s waitFor
|
||||
// window is only just wide enough — give the request room to land.
|
||||
await waitFor(() => {
|
||||
expect(saved).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
expect(saved!.finish_photo_restore_plate).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('general settings', () => {
|
||||
it('shows date format setting', async () => {
|
||||
render(<SettingsPage />);
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,7 @@ export interface AppSettings {
|
|||
auto_archive: boolean;
|
||||
save_thumbnails: boolean;
|
||||
capture_finish_photo: boolean;
|
||||
finish_photo_restore_plate: boolean;
|
||||
default_filament_cost: number;
|
||||
currency: string;
|
||||
energy_cost_per_kwh: number;
|
||||
|
|
|
|||
|
|
@ -2452,6 +2452,8 @@ export default {
|
|||
autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
|
||||
saveThumbnailsDescription: 'Vorschaubilder aus 3MF-Dateien extrahieren und speichern',
|
||||
captureFinishPhotoDescription: 'Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist. Bambuddy zeichnet während des Drucks einen kurzen Zeitraffer auf, damit das Foto aus dem Moment vor dem Absenken der Druckplatte stammen kann. Die Zeitraffer-Datei bleibt erhalten, wenn du den Zeitraffer für diesen Druck aktiviert hast, andernfalls wird sie nach Aufnahme des Fotos automatisch gelöscht.',
|
||||
finishPhotoRestorePlate: 'Druckplatte für Abschlussfoto anheben',
|
||||
finishPhotoRestorePlateDescription: 'Der Drucker senkt die Druckplatte am Druckende um etwa 100 mm ab, wodurch der fertige Druck unterhalb des Kamerabildausschnitts liegt. Bambuddy hebt sie wieder bis knapp über die zuletzt gedruckte Schicht an, nimmt das Foto auf und senkt sie danach wieder ab. Wird übersprungen, wenn die Druckhöhe unbekannt ist oder ein weiterer Auftrag in der Warteschlange steht.',
|
||||
ffmpegNotInstalled: 'ffmpeg nicht installiert',
|
||||
ffmpegRequired: 'Kameraaufnahme benötigt ffmpeg. Installieren über <brew>brew install ffmpeg</brew> (macOS) oder <apt>apt install ffmpeg</apt> (Linux).',
|
||||
// Camera
|
||||
|
|
|
|||
|
|
@ -2471,6 +2471,8 @@ export default {
|
|||
autoArchiveDescription: 'Automatically save 3MF files when prints complete',
|
||||
saveThumbnailsDescription: 'Extract and save preview images from 3MF files',
|
||||
captureFinishPhotoDescription: 'Take a photo from printer camera when print completes. Bambuddy records a brief timelapse during the print so the photo can be sourced from the moment before the bed drops; the timelapse file is kept if you enabled timelapse for this print, otherwise it is deleted automatically after the photo is captured.',
|
||||
finishPhotoRestorePlate: 'Restore plate for finish photo',
|
||||
finishPhotoRestorePlateDescription: 'The printer drops the build plate about 100 mm when a print ends, leaving the finished print below the camera\'s framing. Bambuddy raises it back to just above the last printed layer, takes the photo, then lowers it again. Skipped when the print height is unknown or another job is queued.',
|
||||
ffmpegNotInstalled: 'ffmpeg not installed',
|
||||
ffmpegRequired: 'Camera capture requires ffmpeg. Install it via <brew>brew install ffmpeg</brew> (macOS) or <apt>apt install ffmpeg</apt> (Linux).',
|
||||
// Camera
|
||||
|
|
|
|||
|
|
@ -2455,6 +2455,8 @@ export default {
|
|||
autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
|
||||
saveThumbnailsDescription: 'Extraer y guardar imágenes de vista previa de los archivos 3MF',
|
||||
captureFinishPhotoDescription: 'Tomar una foto desde la cámara de la impresora cuando se completa la impresión. Bambuddy graba un breve timelapse durante la impresión para que la foto pueda obtenerse del momento previo al descenso de la cama; el archivo del timelapse se conserva si activaste el timelapse para esta impresión, de lo contrario se elimina automáticamente tras capturar la foto.',
|
||||
finishPhotoRestorePlate: 'Elevar la cama para la foto final',
|
||||
finishPhotoRestorePlateDescription: 'La impresora baja la cama unos 100 mm al terminar una impresión, dejando la pieza terminada por debajo del encuadre de la cámara. Bambuddy la vuelve a subir hasta justo encima de la última capa impresa, toma la foto y luego la baja de nuevo. Se omite si se desconoce la altura de la impresión o si hay otro trabajo en cola.',
|
||||
ffmpegNotInstalled: 'ffmpeg no instalado',
|
||||
ffmpegRequired: 'La captura de cámara requiere ffmpeg. Instálelo mediante <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
|
||||
// Camera
|
||||
|
|
|
|||
|
|
@ -2406,6 +2406,8 @@ export default {
|
|||
autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
|
||||
saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
|
||||
captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\'imprimante à la fin de l\'impression. Bambuddy enregistre un court timelapse pendant l\'impression afin que la photo puisse provenir du moment précédant l\'abaissement du plateau ; le fichier du timelapse est conservé si vous avez activé le timelapse pour cette impression, sinon il est supprimé automatiquement après la capture de la photo.',
|
||||
finishPhotoRestorePlate: 'Remonter le plateau pour la photo finale',
|
||||
finishPhotoRestorePlateDescription: 'L\'imprimante abaisse le plateau d\'environ 100 mm à la fin d\'une impression, plaçant l\'objet terminé sous le cadrage de la caméra. Bambuddy le remonte juste au-dessus de la dernière couche imprimée, prend la photo, puis le rabaisse. Ignoré si la hauteur d\'impression est inconnue ou si un autre travail est en file d\'attente.',
|
||||
ffmpegNotInstalled: 'ffmpeg non installé',
|
||||
ffmpegRequired: 'La capture caméra nécessite ffmpeg. Installez-le via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
|
||||
camera: 'Caméra',
|
||||
|
|
|
|||
|
|
@ -2405,6 +2405,8 @@ export default {
|
|||
autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
|
||||
saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
|
||||
captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa. Bambuddy registra un breve timelapse durante la stampa in modo che la foto possa essere ricavata dal momento precedente all\'abbassamento del piatto; il file del timelapse viene mantenuto se hai abilitato il timelapse per questa stampa, altrimenti viene eliminato automaticamente dopo l\'acquisizione della foto.',
|
||||
finishPhotoRestorePlate: 'Solleva il piatto per la foto finale',
|
||||
finishPhotoRestorePlateDescription: 'La stampante abbassa il piatto di circa 100 mm al termine di una stampa, lasciando l\'oggetto finito sotto l\'inquadratura della fotocamera. Bambuddy lo risolleva fino a poco sopra l\'ultimo strato stampato, scatta la foto e poi lo riabbassa. Ignorato se l\'altezza di stampa è sconosciuta o se un altro lavoro è in coda.',
|
||||
ffmpegNotInstalled: 'ffmpeg non installato',
|
||||
ffmpegRequired: 'L\'acquisizione dalla fotocamera richiede ffmpeg. Installalo tramite <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
|
||||
camera: 'Fotocamera',
|
||||
|
|
|
|||
|
|
@ -2451,6 +2451,8 @@ export default {
|
|||
autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
|
||||
saveThumbnailsDescription: '3MFファイルからプレビュー画像を抽出して保存',
|
||||
captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影します。Bambuddy は印刷中に短いタイムラプスを記録し、ベッドが下がる前の瞬間から写真を取得できるようにします。この印刷でタイムラプスを有効にしていた場合はタイムラプスファイルが保存され、それ以外の場合は写真の取得後に自動的に削除されます。',
|
||||
finishPhotoRestorePlate: '完了写真のためにプレートを戻す',
|
||||
finishPhotoRestorePlateDescription: 'プリンターは印刷終了時にビルドプレートを約 100 mm 下降させるため、完成した造形物がカメラの画角より下に来ます。Bambuddy はプレートを最終印刷レイヤーのすぐ上まで戻して写真を撮影し、その後再び下降させます。造形高さが不明な場合や次のジョブがキューにある場合はスキップされます。',
|
||||
ffmpegNotInstalled: 'ffmpegがインストールされていません',
|
||||
ffmpegRequired: 'カメラ撮影にはffmpegが必要です。<brew>brew install ffmpeg</brew>(macOS)または<apt>apt install ffmpeg</apt>(Linux)でインストールしてください。',
|
||||
// Camera
|
||||
|
|
|
|||
|
|
@ -2321,6 +2321,8 @@ export default {
|
|||
autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
|
||||
saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
|
||||
captureFinishPhotoDescription: '인쇄 완료 시 프린터 카메라로 사진 촬영. Bambuddy는 인쇄 중 짧은 타임랩스를 기록하여 베드가 내려가기 전 순간에서 사진을 가져올 수 있도록 합니다. 이 인쇄에 대해 타임랩스를 활성화한 경우 타임랩스 파일이 보관되며, 그렇지 않으면 사진 촬영 후 자동으로 삭제됩니다.',
|
||||
finishPhotoRestorePlate: '완료 사진을 위해 베드 올리기',
|
||||
finishPhotoRestorePlateDescription: '프린터는 인쇄가 끝나면 베드를 약 100 mm 내리므로 완성된 출력물이 카메라 화각 아래에 놓입니다. Bambuddy는 베드를 마지막 인쇄 레이어 바로 위까지 다시 올려 사진을 찍은 뒤 다시 내립니다. 출력 높이를 알 수 없거나 다른 작업이 대기 중이면 건너뜁니다.',
|
||||
ffmpegNotInstalled: 'ffmpeg 미설치',
|
||||
ffmpegRequired: '카메라 캡처에 ffmpeg가 필요합니다. macOS에서는 <brew>brew install ffmpeg</brew>, Linux에서는 <apt>apt install ffmpeg</apt>로 설치하세요.',
|
||||
camera: '카메라',
|
||||
|
|
|
|||
|
|
@ -2405,6 +2405,8 @@ export default {
|
|||
autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
|
||||
saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
|
||||
captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída. Bambuddy grava um timelapse curto durante a impressão para que a foto possa ser obtida do momento antes da mesa descer; o arquivo do timelapse é mantido se você habilitou o timelapse para esta impressão, caso contrário ele é excluído automaticamente após a captura da foto.',
|
||||
finishPhotoRestorePlate: 'Elevar a mesa para a foto final',
|
||||
finishPhotoRestorePlateDescription: 'A impressora baixa a mesa cerca de 100 mm ao fim de uma impressão, deixando a peça pronta abaixo do enquadramento da câmera. O Bambuddy a eleva novamente até logo acima da última camada impressa, tira a foto e depois a baixa de novo. Ignorado quando a altura da impressão é desconhecida ou há outro trabalho na fila.',
|
||||
ffmpegNotInstalled: 'ffmpeg não instalado',
|
||||
ffmpegRequired: 'A captura de câmera requer ffmpeg. Instale via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
|
||||
camera: 'Câmera',
|
||||
|
|
|
|||
|
|
@ -2322,6 +2322,8 @@ export default {
|
|||
autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
|
||||
saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
|
||||
captureFinishPhotoDescription: "Сделать снимок камерой принтера после завершения печати. Во время печати Bambuddy записывает короткий таймлапс, чтобы получить кадр до опускания стола. Если таймлапс был включён для задания, файл сохранится; иначе после получения снимка он будет автоматически удалён.",
|
||||
finishPhotoRestorePlate: "Поднимать стол для финального снимка",
|
||||
finishPhotoRestorePlateDescription: "По окончании печати принтер опускает стол примерно на 100 мм, и готовая модель оказывается ниже кадра камеры. Bambuddy поднимает стол обратно чуть выше последнего напечатанного слоя, делает снимок и снова опускает его. Пропускается, если высота печати неизвестна или в очереди есть другое задание.",
|
||||
ffmpegNotInstalled: "ffmpeg не установлен",
|
||||
ffmpegRequired: "Для захвата изображения требуется ffmpeg. Установите его командой <brew>brew install ffmpeg</brew> в macOS или <apt>apt install ffmpeg</apt> в Linux.",
|
||||
camera: "Камера",
|
||||
|
|
|
|||
|
|
@ -2456,6 +2456,8 @@ export default {
|
|||
autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
|
||||
saveThumbnailsDescription: '3MF dosyalarından önizleme görüntülerini çıkar ve kaydet',
|
||||
captureFinishPhotoDescription: 'Baskı tamamlandığında yazıcı kamerasından bir fotoğraf çek. Bambuddy, baskı sırasında kısa bir zaman atlamalı kayıt yapar, böylece fotoğraf tabla inmeden önceki andan alınabilir. Bu baskı için zaman atlamalı kaydı etkinleştirdiyseniz dosya saklanır, aksi takdirde fotoğraf çekildikten sonra otomatik olarak silinir.',
|
||||
finishPhotoRestorePlate: 'Bitiş fotoğrafı için tablayı yükselt',
|
||||
finishPhotoRestorePlateDescription: 'Yazıcı, baskı bittiğinde tablayı yaklaşık 100 mm aşağı indirir ve tamamlanmış baskı kameranın çerçevesinin altında kalır. Bambuddy tablayı son basılan katmanın hemen üzerine geri kaldırır, fotoğrafı çeker ve ardından tekrar indirir. Baskı yüksekliği bilinmiyorsa veya kuyrukta başka bir iş varsa atlanır.',
|
||||
ffmpegNotInstalled: 'ffmpeg yüklü değil',
|
||||
ffmpegRequired: 'Kamera yakalama ffmpeg gerektirir. <brew>brew install ffmpeg</brew> (macOS) veya <apt>apt install ffmpeg</apt> (Linux) ile yükleyin.',
|
||||
// Kamera
|
||||
|
|
|
|||
|
|
@ -2471,6 +2471,8 @@ export default {
|
|||
autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
|
||||
saveThumbnailsDescription: "Витягніть і збережіть зображення попереднього перегляду з файлів 3MF.",
|
||||
captureFinishPhotoDescription: "Зробіть фотографію з камери принтера після завершення друку. Bambuddy записує короткий проміжок часу під час друку, щоб фотографію можна було отримати з моменту, коли стіл опускається; файл уповільненої зйомки зберігається, якщо ви ввімкнули уповільнену зйомку для цього друку, інакше він автоматично видаляється після зйомки фотографії.",
|
||||
finishPhotoRestorePlate: "Піднімати стіл для фінального знімка",
|
||||
finishPhotoRestorePlateDescription: "Після завершення друку принтер опускає стіл приблизно на 100 мм, і готова модель опиняється нижче кадру камери. Bambuddy піднімає стіл назад трохи вище останнього надрукованого шару, робить знімок і знову опускає його. Пропускається, якщо висота друку невідома або в черзі є інше завдання.",
|
||||
ffmpegNotInstalled: "ffmpeg не встановлено",
|
||||
ffmpegRequired: "Для зйомки камерою потрібен ffmpeg. Встановіть його за допомогою <brew>brew install ffmpeg</brew> (macOS) або <apt>apt install ffmpeg</apt> (Linux).",
|
||||
// Camera
|
||||
|
|
|
|||
|
|
@ -2450,6 +2450,8 @@ export default {
|
|||
autoArchiveDescription: '打印完成时自动保存3MF文件',
|
||||
saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
|
||||
captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照。Bambuddy 会在打印期间录制一段短延时摄影,以便从热床下降前的瞬间获取照片;如果您为本次打印启用了延时摄影,文件将保留,否则会在拍照完成后自动删除。',
|
||||
finishPhotoRestorePlate: '为完成照片抬升热床',
|
||||
finishPhotoRestorePlateDescription: '打印结束时打印机会将热床下降约 100 mm,使完成的模型落在相机取景范围之下。Bambuddy 会将热床抬回到最后一层打印高度略上方,拍摄照片后再次下降。若打印高度未知或队列中还有其他任务,则跳过此步骤。',
|
||||
ffmpegNotInstalled: '未安装ffmpeg',
|
||||
ffmpegRequired: '摄像头捕获需要ffmpeg。通过 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安装。',
|
||||
camera: '摄像头',
|
||||
|
|
|
|||
|
|
@ -2450,6 +2450,8 @@ export default {
|
|||
autoArchiveDescription: '列印完成時自動儲存3MF檔案',
|
||||
saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
|
||||
captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照。Bambuddy 會在列印期間錄製一段短縮時攝影,以便從熱床下降前的瞬間取得照片;如果您為本次列印啟用了縮時攝影,檔案將保留,否則會在拍照完成後自動刪除。',
|
||||
finishPhotoRestorePlate: '為完成照片抬升熱床',
|
||||
finishPhotoRestorePlateDescription: '列印結束時印表機會將熱床下降約 100 mm,使完成的模型落在相機取景範圍之下。Bambuddy 會將熱床抬回到最後一層列印高度略上方,拍攝照片後再次下降。若列印高度未知或佇列中還有其他任務,則跳過此步驟。',
|
||||
ffmpegNotInstalled: '未安裝ffmpeg',
|
||||
ffmpegRequired: '攝影機捕獲需要ffmpeg。透過 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安裝。',
|
||||
camera: '攝影機',
|
||||
|
|
|
|||
|
|
@ -959,6 +959,7 @@ export function SettingsPage() {
|
|||
settings.auto_archive !== localSettings.auto_archive ||
|
||||
settings.save_thumbnails !== localSettings.save_thumbnails ||
|
||||
settings.capture_finish_photo !== localSettings.capture_finish_photo ||
|
||||
(settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
|
||||
settings.default_filament_cost !== localSettings.default_filament_cost ||
|
||||
settings.currency !== localSettings.currency ||
|
||||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
|
||||
|
|
@ -1058,6 +1059,10 @@ export function SettingsPage() {
|
|||
auto_archive: localSettings.auto_archive,
|
||||
save_thumbnails: localSettings.save_thumbnails,
|
||||
capture_finish_photo: localSettings.capture_finish_photo,
|
||||
// #2547: `?? true` mirrors the toggle's own default, so an install
|
||||
// whose settings payload predates this field saves what the user is
|
||||
// actually looking at rather than `undefined`.
|
||||
finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true,
|
||||
default_filament_cost: localSettings.default_filament_cost,
|
||||
currency: localSettings.currency,
|
||||
energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
|
||||
|
|
@ -1897,6 +1902,28 @@ export function SettingsPage() {
|
|||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
{/* #2547: only meaningful while finish photos are being taken at
|
||||
all, so it hangs off the toggle above rather than standing
|
||||
alone in the list. */}
|
||||
{localSettings.capture_finish_photo && (
|
||||
<div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<p className="text-white">{t('settings.finishPhotoRestorePlate')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('settings.finishPhotoRestorePlateDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.finish_photo_restore_plate ?? true}
|
||||
onChange={(e) => updateSetting('finish_photo_restore_plate', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-fmZ_9rRe.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DYtiDfeG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue