|
@@ -8,8 +8,10 @@ from fastapi.responses import Response
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy import func, select
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
+from backend.app.core import database
|
|
|
from backend.app.core.auth import (
|
|
from backend.app.core.auth import (
|
|
|
RequireCameraStreamTokenIfAuthEnabled,
|
|
RequireCameraStreamTokenIfAuthEnabled,
|
|
|
|
|
+ RequireOverlayTokenIfAuthEnabled,
|
|
|
RequirePermissionIfAuthEnabled,
|
|
RequirePermissionIfAuthEnabled,
|
|
|
is_auth_enabled,
|
|
is_auth_enabled,
|
|
|
)
|
|
)
|
|
@@ -50,14 +52,17 @@ from backend.app.services.bambu_ftp import (
|
|
|
)
|
|
)
|
|
|
from backend.app.services.printer_diagnostic import run_connection_diagnostic
|
|
from backend.app.services.printer_diagnostic import run_connection_diagnostic
|
|
|
from backend.app.services.printer_manager import (
|
|
from backend.app.services.printer_manager import (
|
|
|
|
|
+ drying_screen_only,
|
|
|
get_derived_status_name,
|
|
get_derived_status_name,
|
|
|
printer_manager,
|
|
printer_manager,
|
|
|
|
|
+ resolve_expected_tray,
|
|
|
resolve_plate_id,
|
|
resolve_plate_id,
|
|
|
supports_chamber_heater,
|
|
supports_chamber_heater,
|
|
|
supports_chamber_temp,
|
|
supports_chamber_temp,
|
|
|
supports_drying,
|
|
supports_drying,
|
|
|
supports_drying_while_printing,
|
|
supports_drying_while_printing,
|
|
|
)
|
|
)
|
|
|
|
|
+from backend.app.utils.filament_ids import filament_id_to_setting_id
|
|
|
from backend.app.utils.http import build_content_disposition
|
|
from backend.app.utils.http import build_content_disposition
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
@@ -535,6 +540,7 @@ async def get_printer_status(
|
|
|
drying_temp=tray_data.get("drying_temp"),
|
|
drying_temp=tray_data.get("drying_temp"),
|
|
|
drying_time=tray_data.get("drying_time"),
|
|
drying_time=tray_data.get("drying_time"),
|
|
|
state=tray_data.get("state"),
|
|
state=tray_data.get("state"),
|
|
|
|
|
+ exists=tray_data.get("exists"),
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
# Prefer humidity_raw (percentage) over humidity (index 1-5)
|
|
# Prefer humidity_raw (percentage) over humidity (index 1-5)
|
|
@@ -763,6 +769,26 @@ async def get_printer_status(
|
|
|
ams_mapping=ams_mapping,
|
|
ams_mapping=ams_mapping,
|
|
|
ams_extruder_map=ams_extruder_map,
|
|
ams_extruder_map=ams_extruder_map,
|
|
|
tray_now=tray_now,
|
|
tray_now=tray_now,
|
|
|
|
|
+ # Runout guidance (#2587): resolve the firmware's target/previous slot to a
|
|
|
|
|
+ # global tray ID, but only while PAUSED — the moment the operator needs it.
|
|
|
|
|
+ expected_tray=(
|
|
|
|
|
+ resolve_expected_tray(
|
|
|
|
|
+ state.tray_tar,
|
|
|
|
|
+ [(u.id, u.is_ams_ht) for u in ams_units],
|
|
|
|
|
+ raw_data.get("mapping"),
|
|
|
|
|
+ )
|
|
|
|
|
+ if state.state == "PAUSE"
|
|
|
|
|
+ else None
|
|
|
|
|
+ ),
|
|
|
|
|
+ previous_tray=(
|
|
|
|
|
+ resolve_expected_tray(
|
|
|
|
|
+ state.tray_pre,
|
|
|
|
|
+ [(u.id, u.is_ams_ht) for u in ams_units],
|
|
|
|
|
+ raw_data.get("mapping"),
|
|
|
|
|
+ )
|
|
|
|
|
+ if state.state == "PAUSE"
|
|
|
|
|
+ else None
|
|
|
|
|
+ ),
|
|
|
ams_status_main=state.ams_status_main,
|
|
ams_status_main=state.ams_status_main,
|
|
|
ams_status_sub=state.ams_status_sub,
|
|
ams_status_sub=state.ams_status_sub,
|
|
|
mc_print_sub_stage=state.mc_print_sub_stage,
|
|
mc_print_sub_stage=state.mc_print_sub_stage,
|
|
@@ -778,6 +804,7 @@ async def get_printer_status(
|
|
|
awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
|
|
awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
|
|
|
supports_drying=supports_drying(printer.model, state.firmware_version),
|
|
supports_drying=supports_drying(printer.model, state.firmware_version),
|
|
|
supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
|
|
supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
|
|
|
|
|
+ drying_screen_only=drying_screen_only(printer.model),
|
|
|
supports_chamber_heater=supports_chamber_heater(printer.model),
|
|
supports_chamber_heater=supports_chamber_heater(printer.model),
|
|
|
current_archive_id=current_archive_id,
|
|
current_archive_id=current_archive_id,
|
|
|
current_plate_id=current_plate_id,
|
|
current_plate_id=current_plate_id,
|
|
@@ -795,6 +822,70 @@ async def get_printer_status(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+@router.get("/{printer_id}/overlay-status")
|
|
|
|
|
+async def get_overlay_status(
|
|
|
|
|
+ printer_id: int,
|
|
|
|
|
+ _: None = RequireOverlayTokenIfAuthEnabled,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+) -> dict:
|
|
|
|
|
+ """Everything the streaming overlay (#2613) draws for one printer.
|
|
|
|
|
+
|
|
|
|
|
+ A token-authenticated sibling of ``get_printer_status`` for embeds with no
|
|
|
|
|
+ login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
|
|
|
|
|
+ Deliberately flat and minimal (name, camera rotation, live print state, and
|
|
|
|
|
+ the one setting the overlay reads) rather than the full ``PrinterStatus``:
|
|
|
|
|
+ a token holder gets exactly the fields the overlay renders, nothing more.
|
|
|
|
|
+
|
|
|
|
|
+ Unlike the Cam Wall feed this *includes the print filename* — the overlay
|
|
|
|
|
+ names the part on screen — which is why it sits behind its own ``overlay``
|
|
|
|
|
+ scope rather than ``camwall``.
|
|
|
|
|
+ """
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
+ printer = result.scalar_one_or_none()
|
|
|
|
|
+ if not printer:
|
|
|
|
|
+ raise HTTPException(404, "Printer not found")
|
|
|
|
|
+
|
|
|
|
|
+ time_format = await get_setting(db, "time_format") or "system"
|
|
|
|
|
+ state = printer_manager.get_status(printer_id)
|
|
|
|
|
+
|
|
|
|
|
+ if not state:
|
|
|
|
|
+ # Never connected this run — mirror get_printer_status()'s disconnected
|
|
|
|
|
+ # shape so the overlay renders its offline state rather than erroring.
|
|
|
|
|
+ return {
|
|
|
|
|
+ "id": printer_id,
|
|
|
|
|
+ "name": printer.name,
|
|
|
|
|
+ "camera_rotation": printer.camera_rotation or 0,
|
|
|
|
|
+ "connected": False,
|
|
|
|
|
+ "state": None,
|
|
|
|
|
+ "current_print": None,
|
|
|
|
|
+ "gcode_file": None,
|
|
|
|
|
+ "progress": None,
|
|
|
|
|
+ "remaining_time": None,
|
|
|
|
|
+ "layer_num": None,
|
|
|
|
|
+ "total_layers": None,
|
|
|
|
|
+ "stg_cur_name": None,
|
|
|
|
|
+ "time_format": time_format,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "id": printer_id,
|
|
|
|
|
+ "name": printer.name,
|
|
|
|
|
+ "camera_rotation": printer.camera_rotation or 0,
|
|
|
|
|
+ "connected": state.connected,
|
|
|
|
|
+ "state": state.state,
|
|
|
|
|
+ "current_print": state.current_print,
|
|
|
|
|
+ "gcode_file": state.gcode_file,
|
|
|
|
|
+ "progress": state.progress,
|
|
|
|
|
+ "remaining_time": state.remaining_time,
|
|
|
|
|
+ "layer_num": state.layer_num,
|
|
|
|
|
+ "total_layers": state.total_layers,
|
|
|
|
|
+ "stg_cur_name": get_derived_status_name(state, printer.model),
|
|
|
|
|
+ "time_format": time_format,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@router.get("/{printer_id}/current-print-user")
|
|
@router.get("/{printer_id}/current-print-user")
|
|
|
async def get_current_print_user(
|
|
async def get_current_print_user(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
@@ -938,6 +1029,15 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
|
|
|
# Cleared on print start alongside _cover_cache.
|
|
# Cleared on print start alongside _cover_cache.
|
|
|
_cover_404_cache: dict[int, set[tuple[str, str]]] = {}
|
|
_cover_404_cache: dict[int, set[tuple[str, str]]] = {}
|
|
|
|
|
|
|
|
|
|
+# In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572).
|
|
|
|
|
+# The farm dashboard mounts a cover tile per printer card, so several browsers
|
|
|
|
|
+# request the same printer's cover in the same instant, all miss the cache, and
|
|
|
|
|
+# each runs the full multi-path FTP lookup + 3MF extraction (one observed live
|
|
|
|
|
+# transfer pulled an 81 MB 3MF while real print uploads were in flight). The
|
|
|
|
|
+# first request to miss becomes the leader; concurrent requests await its future
|
|
|
|
|
+# and then serve from the positive/negative cache it filled.
|
|
|
|
|
+_cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {}
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def clear_cover_cache(printer_id: int) -> None:
|
|
def clear_cover_cache(printer_id: int) -> None:
|
|
|
"""Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
|
|
"""Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
|
|
@@ -949,17 +1049,30 @@ def clear_cover_cache(printer_id: int) -> None:
|
|
|
async def get_printer_cover(
|
|
async def get_printer_cover(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
view: str | None = None,
|
|
view: str | None = None,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
|
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
|
|
):
|
|
):
|
|
|
"""Get the cover image for the current print job.
|
|
"""Get the cover image for the current print job.
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
|
- view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
|
|
|
|
|
|
|
+ view: Optional view type. Use "top" for the top-down build plate view or
|
|
|
|
|
+ "pick" for the slicer's object-ID mask used by skip objects.
|
|
|
Default returns angled 3D perspective view.
|
|
Default returns angled 3D perspective view.
|
|
|
"""
|
|
"""
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
|
|
+ # Fetch the printer in a short-lived session and release the pooled DB
|
|
|
|
|
+ # connection BEFORE the FTP download below. Previously this route took its
|
|
|
|
|
+ # row via Depends(get_db), whose session stays open for the whole request —
|
|
|
|
|
+ # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
|
|
|
|
|
+ # under FTP contention) pinned one pooled connection idle-in-transaction the
|
|
|
|
|
+ # entire time (issue #2572). db is used only for this one SELECT; everything
|
|
|
|
|
+ # after reads already-loaded printer.* scalars (expire_on_commit=False keeps
|
|
|
|
|
+ # them readable), printer_manager, and FTP/zip — no lazy loads.
|
|
|
|
|
+ #
|
|
|
|
|
+ # Reference async_session via the module so the maker is looked up at call
|
|
|
|
|
+ # time — keeps it in sync with reinitialize_database() and lets the test
|
|
|
|
|
+ # harness's patch of backend.app.core.database.async_session take effect.
|
|
|
|
|
+ async with database.async_session() as db:
|
|
|
|
|
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
+ printer = result.scalar_one_or_none()
|
|
|
if not printer:
|
|
if not printer:
|
|
|
raise HTTPException(404, "Printer not found")
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
@@ -1002,6 +1115,53 @@ async def get_printer_cover(
|
|
|
if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
|
|
if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
|
|
|
raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
|
|
raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
|
|
|
|
|
|
|
|
|
|
+ # Coalesce concurrent downloads for the same cover (#2572). The positive and
|
|
|
|
|
+ # negative caches were just checked above; if another request is already
|
|
|
|
|
+ # downloading this exact cover, wait for it and serve from the cache it fills
|
|
|
|
|
+ # instead of launching a duplicate multi-path FTP + 3MF extraction.
|
|
|
|
|
+ inflight_key = (printer_id, subtask_name, view_key)
|
|
|
|
|
+ leader = _cover_inflight.get(inflight_key)
|
|
|
|
|
+ if leader is not None:
|
|
|
|
|
+ # shield() so our own cancellation can't cancel the shared leader.
|
|
|
|
|
+ try:
|
|
|
|
|
+ await asyncio.shield(leader)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
|
|
|
|
|
+ return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
|
|
|
|
|
+ if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
|
|
|
|
|
+ raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
|
|
|
|
|
+ # Leader finished without filling either cache (a transient 503) — fall
|
|
|
|
|
+ # through and try the download ourselves.
|
|
|
|
|
+
|
|
|
|
|
+ fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
|
|
|
+ _cover_inflight[inflight_key] = fut
|
|
|
|
|
+ try:
|
|
|
|
|
+ image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
|
|
|
|
|
+ return Response(content=image_data, media_type="image/png")
|
|
|
|
|
+ finally:
|
|
|
|
|
+ if not fut.done():
|
|
|
|
|
+ fut.set_result(None)
|
|
|
|
|
+ _cover_inflight.pop(inflight_key, None)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _produce_cover_image(
|
|
|
|
|
+ printer: Printer,
|
|
|
|
|
+ printer_id: int,
|
|
|
|
|
+ subtask_name: str,
|
|
|
|
|
+ view: str | None,
|
|
|
|
|
+ view_key: str,
|
|
|
|
|
+ plate_num: int | None,
|
|
|
|
|
+ cache_key: tuple[str, str],
|
|
|
|
|
+) -> bytes:
|
|
|
|
|
+ """Download the active-print 3MF and extract its cover thumbnail (#2572).
|
|
|
|
|
+
|
|
|
|
|
+ Split out of ``get_printer_cover`` so concurrent requests for the same cover
|
|
|
|
|
+ can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
|
|
|
|
|
+ on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
|
|
|
|
|
+ failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
|
|
|
|
|
+ work — the caller already released the pooled connection before this runs.
|
|
|
|
|
+ """
|
|
|
# Build possible 3MF filenames from subtask_name
|
|
# Build possible 3MF filenames from subtask_name
|
|
|
# Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
|
|
# Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
|
|
|
# or just "name.3mf" (uploaded directly)
|
|
# or just "name.3mf" (uploaded directly)
|
|
@@ -1138,7 +1298,14 @@ async def get_printer_cover(
|
|
|
# Try common thumbnail paths in 3MF files
|
|
# Try common thumbnail paths in 3MF files
|
|
|
# Use plate_num to get the correct plate's thumbnail for multi-plate projects
|
|
# Use plate_num to get the correct plate's thumbnail for multi-plate projects
|
|
|
# Use top-down view if requested (better for skip objects modal)
|
|
# Use top-down view if requested (better for skip objects modal)
|
|
|
- if view == "top":
|
|
|
|
|
|
|
+ if view == "pick":
|
|
|
|
|
+ # Only the active plate's mask, with no fallback: every other view
|
|
|
|
|
+ # falls back to plate 1 because a slightly wrong picture is better
|
|
|
|
|
+ # than none, but a mask is coordinates, not decoration. Plate 1's
|
|
|
|
|
+ # mask over plate 3's layout would resolve clicks to whichever
|
|
|
|
|
+ # object happened to occupy that pixel on a different plate.
|
|
|
|
|
+ thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
|
|
|
|
|
+ elif view == "top":
|
|
|
thumbnail_paths = [
|
|
thumbnail_paths = [
|
|
|
f"Metadata/top_{plate_num}.png",
|
|
f"Metadata/top_{plate_num}.png",
|
|
|
# Fall back to plate 1 if specific plate not found
|
|
# Fall back to plate 1 if specific plate not found
|
|
@@ -1165,18 +1332,25 @@ async def get_printer_cover(
|
|
|
if printer_id not in _cover_cache:
|
|
if printer_id not in _cover_cache:
|
|
|
_cover_cache[printer_id] = {}
|
|
_cover_cache[printer_id] = {}
|
|
|
_cover_cache[printer_id][(subtask_name, view_key)] = image_data
|
|
_cover_cache[printer_id][(subtask_name, view_key)] = image_data
|
|
|
- return Response(content=image_data, media_type="image/png")
|
|
|
|
|
|
|
+ return image_data
|
|
|
except KeyError:
|
|
except KeyError:
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
- # If no specific thumbnail found, try any PNG in Metadata
|
|
|
|
|
- for name in zf.namelist():
|
|
|
|
|
- if name.startswith("Metadata/") and name.endswith(".png"):
|
|
|
|
|
- image_data = zf.read(name)
|
|
|
|
|
- if printer_id not in _cover_cache:
|
|
|
|
|
- _cover_cache[printer_id] = {}
|
|
|
|
|
- _cover_cache[printer_id][(subtask_name, view_key)] = image_data
|
|
|
|
|
- return Response(content=image_data, media_type="image/png")
|
|
|
|
|
|
|
+ # If no specific thumbnail found, try any PNG in Metadata. Never for
|
|
|
|
|
+ # "pick": handing back a rendered thumbnail in place of the object-ID
|
|
|
|
|
+ # mask is worse than nothing, because the caller can't tell the
|
|
|
|
|
+ # difference and decodes the render's pixel colours as object IDs —
|
|
|
|
|
+ # dark pixels yield small integers that collide with real IDs, so a
|
|
|
|
|
+ # click would select an arbitrary object and skip it irreversibly.
|
|
|
|
|
+ # A 404 is what tells the UI to fall back to the checklist.
|
|
|
|
|
+ if view != "pick":
|
|
|
|
|
+ for name in zf.namelist():
|
|
|
|
|
+ if name.startswith("Metadata/") and name.endswith(".png"):
|
|
|
|
|
+ image_data = zf.read(name)
|
|
|
|
|
+ if printer_id not in _cover_cache:
|
|
|
|
|
+ _cover_cache[printer_id] = {}
|
|
|
|
|
+ _cover_cache[printer_id][(subtask_name, view_key)] = image_data
|
|
|
|
|
+ return image_data
|
|
|
|
|
|
|
|
_cover_404_cache.setdefault(printer_id, set()).add(cache_key)
|
|
_cover_404_cache.setdefault(printer_id, set()).add(cache_key)
|
|
|
raise HTTPException(404, "No thumbnail found in 3MF file")
|
|
raise HTTPException(404, "No thumbnail found in 3MF file")
|
|
@@ -1196,18 +1370,37 @@ async def get_printer_cover(
|
|
|
# ============================================
|
|
# ============================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _load_printer_or_404(printer_id: int) -> Printer:
|
|
|
|
|
+ """Load a printer in a short-lived session, releasing the pooled DB
|
|
|
|
|
+ connection before the caller starts any FTP/network I/O (#2572).
|
|
|
|
|
+
|
|
|
|
|
+ The file-manager and storage routes talk FTP to the printer, which can
|
|
|
|
|
+ block for the full socket timeout — longer when a saturated FTP pool backs
|
|
|
|
|
+ up. Holding the request's Depends(get_db) session across that FTP pinned one
|
|
|
|
|
+ pooled connection idle-in-transaction per in-flight request, a top cause of
|
|
|
|
|
+ pool exhaustion on large farms. The returned row's scalar columns stay
|
|
|
|
|
+ readable after the session closes (expire_on_commit=False). Raises 404 when
|
|
|
|
|
+ the printer doesn't exist.
|
|
|
|
|
+
|
|
|
|
|
+ Reference async_session via the module so the maker is resolved at call time
|
|
|
|
|
+ — keeps it in sync with reinitialize_database() and lets tests patch it.
|
|
|
|
|
+ """
|
|
|
|
|
+ async with database.async_session() as db:
|
|
|
|
|
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
+ printer = result.scalar_one_or_none()
|
|
|
|
|
+ if not printer:
|
|
|
|
|
+ raise HTTPException(404, "Printer not found")
|
|
|
|
|
+ return printer
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@router.get("/{printer_id}/files")
|
|
@router.get("/{printer_id}/files")
|
|
|
async def list_printer_files(
|
|
async def list_printer_files(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
path: str = "/",
|
|
path: str = "/",
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""List files on the printer at the specified path."""
|
|
"""List files on the printer at the specified path."""
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
|
|
|
|
|
@@ -1226,13 +1419,9 @@ async def download_printer_file(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
path: str,
|
|
path: str,
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Download a file from the printer."""
|
|
"""Download a file from the printer."""
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
|
if data is None:
|
|
if data is None:
|
|
@@ -1267,16 +1456,11 @@ async def get_printer_file_gcode(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
path: str,
|
|
path: str,
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Get gcode for a file stored on a printer (for preview)."""
|
|
"""Get gcode for a file stored on a printer (for preview)."""
|
|
|
import io
|
|
import io
|
|
|
|
|
|
|
|
- # Validate printer
|
|
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
|
if data is None:
|
|
if data is None:
|
|
@@ -1306,7 +1490,6 @@ async def get_printer_file_plates(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
path: str = Query(..., description="Full path to the 3MF file on the printer"),
|
|
path: str = Query(..., description="Full path to the 3MF file on the printer"),
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Get available plates from a multi-plate 3MF file stored on a printer."""
|
|
"""Get available plates from a multi-plate 3MF file stored on a printer."""
|
|
|
import io
|
|
import io
|
|
@@ -1314,11 +1497,7 @@ async def get_printer_file_plates(
|
|
|
|
|
|
|
|
import defusedxml.ElementTree as ET
|
|
import defusedxml.ElementTree as ET
|
|
|
|
|
|
|
|
- # Validate printer
|
|
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
filename = path.split("/")[-1]
|
|
filename = path.split("/")[-1]
|
|
|
if not filename.lower().endswith(".3mf"):
|
|
if not filename.lower().endswith(".3mf"):
|
|
@@ -1551,15 +1730,11 @@ async def get_printer_file_plate_thumbnail(
|
|
|
plate_index: int,
|
|
plate_index: int,
|
|
|
path: str = Query(..., description="Full path to the 3MF file on the printer"),
|
|
path: str = Query(..., description="Full path to the 3MF file on the printer"),
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Get a plate thumbnail image from a printer-stored 3MF file."""
|
|
"""Get a plate thumbnail image from a printer-stored 3MF file."""
|
|
|
import io
|
|
import io
|
|
|
|
|
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
|
|
|
if data is None:
|
|
if data is None:
|
|
@@ -1582,7 +1757,6 @@ async def download_printer_files_as_zip(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
request: dict,
|
|
request: dict,
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Download multiple files from the printer as a ZIP archive."""
|
|
"""Download multiple files from the printer as a ZIP archive."""
|
|
|
import io
|
|
import io
|
|
@@ -1591,10 +1765,7 @@ async def download_printer_files_as_zip(
|
|
|
if not paths:
|
|
if not paths:
|
|
|
raise HTTPException(400, "No files specified")
|
|
raise HTTPException(400, "No files specified")
|
|
|
|
|
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
# Create ZIP in memory
|
|
# Create ZIP in memory
|
|
|
zip_buffer = io.BytesIO()
|
|
zip_buffer = io.BytesIO()
|
|
@@ -1629,13 +1800,9 @@ async def delete_printer_file(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
path: str,
|
|
path: str,
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Delete a file from the printer."""
|
|
"""Delete a file from the printer."""
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
from backend.app.services.bambu_ftp import DeleteResult
|
|
from backend.app.services.bambu_ftp import DeleteResult
|
|
|
|
|
|
|
@@ -1652,13 +1819,9 @@ async def delete_printer_file(
|
|
|
async def get_printer_storage(
|
|
async def get_printer_storage(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
):
|
|
|
"""Get storage information from the printer."""
|
|
"""Get storage information from the printer."""
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
|
|
- if not printer:
|
|
|
|
|
- raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
+ printer = await _load_printer_or_404(printer_id)
|
|
|
|
|
|
|
|
storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
|
|
storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
|
|
|
|
|
|
|
@@ -1755,6 +1918,11 @@ async def clear_mqtt_logs(
|
|
|
# AMS Drying Endpoints
|
|
# AMS Drying Endpoints
|
|
|
# ============================================
|
|
# ============================================
|
|
|
|
|
|
|
|
|
|
+# The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
|
|
|
|
|
+# — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
|
|
|
|
|
+# (#2533). Refuse the command rather than let the caller believe it landed.
|
|
|
|
|
+_DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
|
|
|
|
|
+
|
|
|
|
|
|
|
|
@router.post("/{printer_id}/drying/start")
|
|
@router.post("/{printer_id}/drying/start")
|
|
|
async def start_drying(
|
|
async def start_drying(
|
|
@@ -1776,6 +1944,8 @@ async def start_drying(
|
|
|
# Server-side guard: reject if this model/firmware doesn't support drying
|
|
# Server-side guard: reject if this model/firmware doesn't support drying
|
|
|
live_state = printer_manager.get_status(printer_id)
|
|
live_state = printer_manager.get_status(printer_id)
|
|
|
firmware = live_state.firmware_version if live_state else None
|
|
firmware = live_state.firmware_version if live_state else None
|
|
|
|
|
+ if drying_screen_only(printer.model):
|
|
|
|
|
+ raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
|
|
|
if not supports_drying(printer.model, firmware):
|
|
if not supports_drying(printer.model, firmware):
|
|
|
raise HTTPException(400, "Drying not supported for this printer model or firmware version")
|
|
raise HTTPException(400, "Drying not supported for this printer model or firmware version")
|
|
|
|
|
|
|
@@ -1848,6 +2018,11 @@ async def stop_drying(
|
|
|
if not printer:
|
|
if not printer:
|
|
|
raise HTTPException(404, "Printer not found")
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
|
|
|
|
|
|
+ # Screen-only models ignore stop just as they ignore start — a cycle running on a
|
|
|
|
|
+ # P1S was started at the printer and has to be ended there too (#2533).
|
|
|
|
|
+ if drying_screen_only(printer.model):
|
|
|
|
|
+ raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
|
|
|
|
|
+
|
|
|
success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
|
|
success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
|
|
|
if not success:
|
|
if not success:
|
|
|
raise HTTPException(400, "Printer not connected")
|
|
raise HTTPException(400, "Printer not connected")
|
|
@@ -2353,6 +2528,18 @@ async def configure_ams_slot(
|
|
|
if kprofile_setting_id:
|
|
if kprofile_setting_id:
|
|
|
effective_setting_id = kprofile_setting_id
|
|
effective_setting_id = kprofile_setting_id
|
|
|
|
|
|
|
|
|
|
+ # Back-fill setting_id from the resolved filament id when the client sent
|
|
|
|
|
+ # none. Built-in / local / Orca-generic presets in the Configure AMS Slot
|
|
|
|
|
+ # modal leave setting_id empty (they carry only a GF* tray_info_idx), and
|
|
|
|
|
+ # the printer treats a filament-id-without-setting-id slot as half
|
|
|
|
|
+ # configured: it shows the new material briefly, then reverts to its
|
|
|
|
|
+ # previously stored profile (#2604). This mirrors the derivation the
|
|
|
|
|
+ # inventory/assignment path already does (inventory.py). filament_id_to_
|
|
|
|
|
+ # setting_id leaves P* user presets and already-GFS* values unchanged, so
|
|
|
|
|
+ # only the empty-setting_id generic paths are affected.
|
|
|
|
|
+ if effective_tray_info_idx and not effective_setting_id:
|
|
|
|
|
+ effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx)
|
|
|
|
|
+
|
|
|
# Always send ams_set_filament_setting — the user explicitly clicked
|
|
# Always send ams_set_filament_setting — the user explicitly clicked
|
|
|
# "Configure Slot", so honor that. Previous versions skipped this for
|
|
# "Configure Slot", so honor that. Previous versions skipped this for
|
|
|
# RFID-tagged slots to preserve the slicer eye icon, but printers cache
|
|
# RFID-tagged slots to preserve the slicer eye icon, but printers cache
|
|
@@ -2548,6 +2735,17 @@ async def configure_ams_slot(
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
|
|
|
+ # Register a read-back verification (#2582) so the tray telemetry that the
|
|
|
|
|
+ # status push below returns can confirm the printer accepted this manual
|
|
|
|
|
+ # slot configuration. Mirrors the inventory/assignment path.
|
|
|
|
|
+ client.register_assignment_verification(
|
|
|
|
|
+ ams_id=ams_id,
|
|
|
|
|
+ tray_id=tray_id,
|
|
|
|
|
+ tray_info_idx=effective_tray_info_idx,
|
|
|
|
|
+ tray_color=tray_color,
|
|
|
|
|
+ cali_idx=cali_idx,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
# Request fresh status push from printer so frontend gets updated data via WebSocket
|
|
# Request fresh status push from printer so frontend gets updated data via WebSocket
|
|
|
logger.info("[configure_ams_slot] Requesting status update from printer")
|
|
logger.info("[configure_ams_slot] Requesting status update from printer")
|
|
|
update_result = client.request_status_update()
|
|
update_result = client.request_status_update()
|
|
@@ -3111,16 +3309,29 @@ async def bed_jog(
|
|
|
"translates this into the right G-code Z sign per printer model."
|
|
"translates this into the right G-code Z sign per printer model."
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
- force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
|
|
|
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
|
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
):
|
|
):
|
|
|
"""Adjust the nozzle-bed gap by a relative distance.
|
|
"""Adjust the nozzle-bed gap by a relative distance.
|
|
|
|
|
|
|
|
- Emits a short G-code sequence via MQTT. When ``force`` is true the soft
|
|
|
|
|
- endstops are disabled for the duration of the move, matching the
|
|
|
|
|
- "ignore and move anyway" option Bambu Studio offers when the printer
|
|
|
|
|
- is not homed.
|
|
|
|
|
|
|
+ Emits a short G-code sequence via MQTT.
|
|
|
|
|
+
|
|
|
|
|
+ Soft-endstop policy (#2579). The printer's software travel limits are the
|
|
|
|
|
+ only thing between a jog button and a bed crash — on Bambu machines the
|
|
|
|
|
+ physical endstops are homing-only (there is no runtime limit switch in the
|
|
|
|
|
+ travel path), so once they are disabled nothing stops the move. The old
|
|
|
|
|
+ code disabled them (``M211 S0``) around every forced jog, and the UI sent
|
|
|
|
|
+ ``force`` on every jog, so the limits were off on every bed move — that is
|
|
|
|
|
+ what let a jog drive the nozzle into the bed on all models (#2579). This
|
|
|
|
|
+ endpoint now emits a **bare relative move and never touches ``M211`` at
|
|
|
|
|
+ all** — byte-for-byte what the printer's own touchscreen jog sends, which
|
|
|
|
|
+ stops at the travel limit. Bambuddy no longer disables the firmware's soft
|
|
|
|
|
+ endstops, and it no longer sends ``M211 S1`` either: that was an unverified
|
|
|
|
|
+ attempt to re-enable a printer left disabled by an older build, and on real
|
|
|
|
|
+ hardware the jog moved past the limit *with* it. If a printer still jogs
|
|
|
|
|
+ past its limits, its endstops were disabled at the firmware level by the old
|
|
|
|
|
+ build — power-cycle it once to restore them; from then on Bambuddy leaves
|
|
|
|
|
+ them alone.
|
|
|
|
|
|
|
|
Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
|
|
Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
|
|
|
is the Z-axis, and Bambu's home convention puts Z=0 at the top with
|
|
is the Z-axis, and Bambu's home convention puts Z=0 at the top with
|
|
@@ -3147,12 +3358,10 @@ async def bed_jog(
|
|
|
|
|
|
|
|
gcode_distance = -distance if is_bed_slinger(printer.model) else distance
|
|
gcode_distance = -distance if is_bed_slinger(printer.model) else distance
|
|
|
|
|
|
|
|
- lines = []
|
|
|
|
|
- if force:
|
|
|
|
|
- lines.append("M211 S0")
|
|
|
|
|
- lines += ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
|
|
|
|
|
- if force:
|
|
|
|
|
- lines.append("M211 S1")
|
|
|
|
|
|
|
+ # Bare relative move — exactly what the touchscreen sends. Never touch M211
|
|
|
|
|
+ # (#2579): the firmware keeps its soft endstops on by default and clamps the
|
|
|
|
|
+ # move at the travel limit.
|
|
|
|
|
+ lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
|
|
|
|
|
|
|
|
if not client.send_gcode("\n".join(lines)):
|
|
if not client.send_gcode("\n".join(lines)):
|
|
|
raise HTTPException(500, "Failed to send bed-jog command")
|
|
raise HTTPException(500, "Failed to send bed-jog command")
|
|
@@ -3187,6 +3396,9 @@ async def xy_jog(
|
|
|
if y:
|
|
if y:
|
|
|
axes.append(f"Y{y:.2f}")
|
|
axes.append(f"Y{y:.2f}")
|
|
|
|
|
|
|
|
|
|
+ # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
|
|
|
|
|
+ # endstops on by default and clamps the move at the travel limit; a printer
|
|
|
|
|
+ # left disabled by an older build is recovered with a power cycle.
|
|
|
if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
|
|
if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
|
|
|
raise HTTPException(500, "Failed to send XY jog command")
|
|
raise HTTPException(500, "Failed to send XY jog command")
|
|
|
|
|
|
|
@@ -3361,7 +3573,14 @@ async def get_printable_objects(
|
|
|
if downloaded and temp_path.exists():
|
|
if downloaded and temp_path.exists():
|
|
|
with open(temp_path, "rb") as f:
|
|
with open(temp_path, "rb") as f:
|
|
|
data = f.read()
|
|
data = f.read()
|
|
|
- objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
|
|
|
|
|
|
|
+ # Scope to the running plate: an all-plates 3MF lists every
|
|
|
|
|
+ # plate's objects, and offering plate 1's while the printer
|
|
|
|
|
+ # runs plate 2 makes every skip a misfire (#2522).
|
|
|
|
|
+ objects, bbox_all = extract_printable_objects_from_3mf(
|
|
|
|
|
+ data,
|
|
|
|
|
+ plate_number=resolve_plate_id(client.state),
|
|
|
|
|
+ include_positions=True,
|
|
|
|
|
+ )
|
|
|
if objects:
|
|
if objects:
|
|
|
client.state.printable_objects = objects
|
|
client.state.printable_objects = objects
|
|
|
client.state.printable_objects_bbox_all = bbox_all
|
|
client.state.printable_objects_bbox_all = bbox_all
|
|
@@ -3721,8 +3940,12 @@ async def ams_load(
|
|
|
- 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
|
|
- 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
|
|
|
- 255: Ext-R on dual-nozzle H2D
|
|
- 255: Ext-R on dual-nozzle H2D
|
|
|
"""
|
|
"""
|
|
|
- if tray_id not in range(16) and tray_id not in (254, 255):
|
|
|
|
|
- raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
|
|
|
|
|
|
|
+ # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
|
|
|
|
|
+ # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
|
|
|
|
|
+ if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ 400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
printer = result.scalar_one_or_none()
|
|
printer = result.scalar_one_or_none()
|