Add background print dispatching + tests

This commit is contained in:
Ryan Ewen
2026-02-20 08:51:13 +01:00
committed by maziggy
parent 732ff634fa
commit 4a625c1ef2
19 changed files with 2112 additions and 304 deletions
+31 -120
View File
@@ -2389,7 +2389,8 @@ async def get_archive_plates(
for gf in gcode_files:
# "Metadata/plate_5.gcode" -> 5
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
# Remove "Metadata/plate_" and ".gcode"
plate_str = gf[15:-6]
plate_indices.append(int(plate_str))
except ValueError:
pass # Skip gcode file with non-numeric plate index
@@ -2792,14 +2793,9 @@ async def reprint_archive(
)
),
):
"""Send an archived 3MF file to a printer and start printing."""
from backend.app.main import register_expected_print
"""Dispatch an archived 3MF file for send/start on a printer."""
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import (
get_ftp_retry_settings,
upload_file_async,
with_ftp_retry,
)
from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
from backend.app.services.printer_manager import printer_manager
user, can_modify_all = auth_result
@@ -2829,139 +2825,54 @@ async def reprint_archive(
if not printer_manager.is_connected(printer_id):
raise HTTPException(400, "Printer is not connected")
# Get the sliced 3MF file path
if not archive.file_path:
raise HTTPException(
404,
"No 3MF file available for this archive. "
"The file could not be downloaded from the printer when the print was recorded.",
)
# Validate archive file exists
file_path = settings.base_dir / archive.file_path
if not file_path.is_file():
raise HTTPException(404, "Archive file not found")
# Upload file to printer via FTP
from backend.app.services.bambu_ftp import delete_file_async
plate_name = body.plate_name
if not plate_name and body.plate_id is not None:
plate_name = f"Plate {body.plate_id}"
# Use a clean filename to avoid issues with double extensions like .gcode.3mf
# The printer might reject filenames with unusual extensions
base_name = archive.filename
if base_name.endswith(".gcode.3mf"):
base_name = base_name[:-10] # Remove .gcode.3mf
elif base_name.endswith(".3mf"):
base_name = base_name[:-4] # Remove .3mf
remote_filename = f"{base_name}.3mf"
remote_path = f"/{remote_filename}"
dispatch_source_name = archive.filename
if plate_name:
dispatch_source_name = f"{archive.filename} • {plate_name}"
# Get FTP retry settings
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
try:
dispatch_result = await background_dispatch.dispatch_reprint_archive(
archive_id=archive_id,
archive_name=dispatch_source_name,
printer_id=printer_id,
printer_name=printer.name,
options=body.model_dump(exclude_none=True),
requested_by_user_id=user.id if user else None,
requested_by_username=user.username if user else None,
)
except DispatchEnqueueRejected as e:
raise HTTPException(status_code=409, detail=str(e)) from e
logger.info(
f"Reprint FTP upload starting: printer={printer.name} ({printer.model}), "
f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
)
# Delete existing file if present (avoids 553 error)
logger.debug("Deleting existing file %s if present...", remote_path)
delete_result = await delete_file_async(
printer.ip_address,
printer.access_code,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
logger.debug("Delete result: %s", delete_result)
if ftp_retry_enabled:
uploaded = await with_ftp_retry(
upload_file_async,
printer.ip_address,
printer.access_code,
file_path,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Upload for reprint to {printer.name}",
)
else:
uploaded = await upload_file_async(
printer.ip_address,
printer.access_code,
file_path,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
if not uploaded:
logger.error(
f"FTP upload failed for reprint: printer={printer.name}, model={printer.model}, "
f"ip={printer.ip_address}, file={remote_filename}. "
"Check logs above for storage diagnostics and specific error codes."
)
raise HTTPException(
500,
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
"See server logs for detailed diagnostics.",
)
# Register this as an expected print so we don't create a duplicate archive
register_expected_print(printer_id, remote_filename, archive_id, ams_mapping=body.ams_mapping)
# Use plate_id from request if provided, otherwise auto-detect from 3MF file
if body.plate_id is not None:
plate_id = body.plate_id
else:
# Auto-detect plate ID from 3MF file (legacy behavior for single-plate files)
plate_id = 1
try:
with zipfile.ZipFile(file_path, "r") as zf:
for name in zf.namelist():
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
# Extract plate number from "Metadata/plate_X.gcode"
plate_str = name[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_id = int(plate_str)
break
except (ValueError, zipfile.BadZipFile, OSError):
pass # Default to plate 1 if detection fails
logger.info(
f"Reprint archive {archive_id}: plate_id={plate_id}, "
f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
)
# Start the print with options
started = printer_manager.start_print(
"Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
archive_id,
printer_id,
remote_filename,
plate_id,
ams_mapping=body.ams_mapping,
timelapse=body.timelapse,
bed_levelling=body.bed_levelling,
flow_cali=body.flow_cali,
vibration_cali=body.vibration_cali,
layer_inspect=body.layer_inspect,
use_ams=body.use_ams,
dispatch_result["dispatch_job_id"],
dispatch_result["dispatch_position"],
)
if not started:
raise HTTPException(500, "Failed to start print")
# Track who started this print (Issue #206)
if user:
printer_manager.set_current_print_user(printer_id, user.id, user.username)
logger.info("Reprint started by user: %s", user.username)
return {
"status": "printing",
"status": "dispatched",
"printer_id": printer_id,
"archive_id": archive_id,
"filename": archive.filename,
"dispatch_job_id": dispatch_result["dispatch_job_id"],
"dispatch_position": dispatch_result["dispatch_position"],
}
@@ -0,0 +1,32 @@
from fastapi import APIRouter, HTTPException
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.services.background_dispatch import background_dispatch
router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
@router.delete("/{job_id}")
async def cancel_dispatch_job(
job_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
):
"""Cancel a background-dispatch job.
Queued jobs are cancelled immediately. Active jobs are marked for
cooperative cancellation and will stop at the next cancellation checkpoint.
"""
result = await background_dispatch.cancel_job(job_id)
if not result["cancelled"]:
raise HTTPException(status_code=404, detail="Dispatch job not found")
return {
"status": "cancelling" if result.get("pending") else "cancelled",
"job_id": result["job_id"],
"source_name": result["source_name"],
"printer_id": result["printer_id"],
"printer_name": result["printer_name"],
}
+26 -131
View File
@@ -54,7 +54,7 @@ from backend.app.schemas.library import (
ZipExtractResponse,
ZipExtractResult,
)
from backend.app.services.archive import ArchiveService, ThreeMFParser
from backend.app.services.archive import ThreeMFParser
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
@@ -1737,23 +1737,15 @@ async def print_library_file(
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
):
"""Print a library file directly.
"""Dispatch a library file for send/start on a printer.
This endpoint:
1. Creates an archive from the library file
2. Uploads the file to the printer
3. Starts the print
The actual send/start work is handled asynchronously by background
dispatch so the UI can continue immediately.
Only sliced files (.gcode or .gcode.3mf) can be printed.
"""
from backend.app.main import register_expected_print
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import (
delete_file_async,
get_ftp_retry_settings,
upload_file_async,
with_ftp_retry,
)
from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
from backend.app.services.printer_manager import printer_manager
# Use defaults if no body provided
@@ -1790,131 +1782,34 @@ async def print_library_file(
if not printer_manager.is_connected(printer_id):
raise HTTPException(status_code=400, detail="Printer is not connected")
# Create archive from the library file
archive_service = ArchiveService(db)
archive = await archive_service.archive_print(
printer_id=printer_id,
source_file=file_path,
original_filename=lib_file.filename,
)
plate_name = body.plate_name
if not plate_name and body.plate_id is not None:
plate_name = f"Plate {body.plate_id}"
if not archive:
raise HTTPException(status_code=500, detail="Failed to create archive")
dispatch_source_name = lib_file.filename
if plate_name:
dispatch_source_name = f"{lib_file.filename} • {plate_name}"
await db.flush()
# Prepare remote filename
base_name = lib_file.filename
if base_name.endswith(".gcode.3mf"):
base_name = base_name[:-10]
elif base_name.endswith(".3mf"):
base_name = base_name[:-4]
remote_filename = f"{base_name}.3mf"
remote_path = f"/{remote_filename}"
# Get FTP retry settings
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
logger.info(
f"Library print FTP upload starting: printer={printer.name} ({printer.model}), "
f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
)
# Delete existing file if present (avoids 553 error)
logger.debug("Deleting existing file %s if present...", remote_path)
delete_result = await delete_file_async(
printer.ip_address,
printer.access_code,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
logger.debug("Delete result: %s", delete_result)
# Upload file to printer
if ftp_retry_enabled:
uploaded = await with_ftp_retry(
upload_file_async,
printer.ip_address,
printer.access_code,
file_path,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Upload for print to {printer.name}",
try:
dispatch_result = await background_dispatch.dispatch_print_library_file(
file_id=file_id,
filename=dispatch_source_name,
printer_id=printer_id,
printer_name=printer.name,
options=body.model_dump(exclude_none=True),
requested_by_user_id=None,
requested_by_username=None,
)
else:
uploaded = await upload_file_async(
printer.ip_address,
printer.access_code,
file_path,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
if not uploaded:
logger.error(
f"FTP upload failed for library print: printer={printer.name}, model={printer.model}, "
f"ip={printer.ip_address}, file={remote_filename}. "
"Check logs above for storage diagnostics and specific error codes."
)
raise HTTPException(
status_code=500,
detail="Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
"See server logs for detailed diagnostics.",
)
# Register this as an expected print so we don't create a duplicate archive
register_expected_print(printer_id, remote_filename, archive.id, ams_mapping=body.ams_mapping)
# Determine plate ID
if body.plate_id is not None:
plate_id = body.plate_id
else:
plate_id = 1
try:
with zipfile.ZipFile(file_path, "r") as zf:
for name in zf.namelist():
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
plate_str = name[15:-6]
plate_id = int(plate_str)
break
except (ValueError, zipfile.BadZipFile, OSError):
pass # Default plate_id=1 if archive is unreadable or has no gcode
logger.info(
f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
)
# Start the print
started = printer_manager.start_print(
printer_id,
remote_filename,
plate_id,
ams_mapping=body.ams_mapping,
timelapse=body.timelapse,
bed_levelling=body.bed_levelling,
flow_cali=body.flow_cali,
vibration_cali=body.vibration_cali,
layer_inspect=body.layer_inspect,
use_ams=body.use_ams,
)
if not started:
raise HTTPException(status_code=500, detail="Failed to start print")
await db.commit()
except DispatchEnqueueRejected as e:
raise HTTPException(status_code=409, detail=str(e)) from e
return {
"status": "printing",
"status": "dispatched",
"printer_id": printer_id,
"archive_id": archive.id,
"archive_id": None,
"filename": lib_file.filename,
"dispatch_job_id": dispatch_result["dispatch_job_id"],
"dispatch_position": dispatch_result["dispatch_position"],
}
+10
View File
@@ -3,6 +3,7 @@ import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from backend.app.core.websocket import ws_manager
from backend.app.services.background_dispatch import background_dispatch
from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
logger = logging.getLogger(__name__)
@@ -27,6 +28,15 @@ async def websocket_endpoint(websocket: WebSocket):
"data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
}
)
dispatch_state = await background_dispatch.get_state()
if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
await websocket.send_json(
{
"type": "background_dispatch",
"data": dispatch_state,
}
)
logger.info("Sent initial status for %s printers", len(statuses))
# Keep connection alive and handle incoming messages
+80 -8
View File
@@ -5,6 +5,75 @@ from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from logging.handlers import RotatingFileHandler
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete, or_, select
from backend.app.api.routes import (
ams_history,
api_keys,
archives,
auth,
background_dispatch as background_dispatch_routes,
camera,
cloud,
discovery,
external_links,
filaments,
firmware,
github_backup,
groups,
kprofiles,
library,
local_presets,
maintenance,
metrics,
notification_templates,
notifications,
pending_uploads,
print_queue,
printers,
projects,
settings as settings_routes,
smart_plugs,
spoolman,
support,
system,
updates,
users,
webhook,
websocket,
)
from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
from backend.app.api.routes.support import init_debug_logging
from backend.app.core.config import APP_VERSION, settings as app_settings
from backend.app.core.database import async_session, init_db
from backend.app.core.websocket import ws_manager
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.archive import ArchiveService
from backend.app.services.background_dispatch import background_dispatch
from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
from backend.app.services.bambu_mqtt import PrinterState
from backend.app.services.github_backup import github_backup_service
from backend.app.services.homeassistant import homeassistant_service
from backend.app.services.mqtt_relay import mqtt_relay
from backend.app.services.notification_service import notification_service
from backend.app.services.print_scheduler import scheduler as print_scheduler
from backend.app.services.printer_manager import (
init_printer_connections,
printer_manager,
printer_state_to_dict,
)
from backend.app.services.smart_plug_manager import smart_plug_manager
from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
from backend.app.services.spoolman_tracking import (
cleanup_tracking as _cleanup_spoolman_tracking,
report_usage as _report_spoolman_usage,
store_print_data as _store_spoolman_print_data,
)
from backend.app.services.tasmota import tasmota_service
# =============================================================================
# Dependency Check - runs before other imports to give helpful error messages
@@ -126,10 +195,8 @@ def check_dependencies():
check_dependencies()
# =============================================================================
from fastapi import FastAPI
# Import settings first for logging configuration
from backend.app.core.config import APP_VERSION, settings as app_settings
# Configure logging based on settings
# DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
@@ -169,9 +236,6 @@ if not app_settings.debug:
logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete, or_, select
from backend.app.api.routes import (
ams_history,
@@ -320,7 +384,8 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
_last_status_broadcast: dict[int, str] = {}
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
# Track printers where we've updated nozzle_count
_nozzle_count_updated: set[int] = set()
async def on_printer_status_change(printer_id: int, state: PrinterState):
@@ -2848,7 +2913,8 @@ _ams_history_task: asyncio.Task | None = None
AMS_HISTORY_INTERVAL = 300 # Record every 5 minutes
AMS_HISTORY_RETENTION_DAYS = 30 # Keep data for 30 days
_ams_cleanup_counter = 0 # Track recordings to trigger periodic cleanup
_ams_alarm_cooldown: dict[str, datetime] = {} # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
# Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
_ams_alarm_cooldown: dict[str, datetime] = {}
AMS_ALARM_COOLDOWN_MINUTES = 60 # Don't send same alarm more than once per hour
@@ -3232,6 +3298,9 @@ async def lifespan(app: FastAPI):
# Start the print scheduler
asyncio.create_task(print_scheduler.run())
# Start background dispatch worker for send/start operations
await background_dispatch.start()
# Start the smart plug scheduler for time-based on/off
smart_plug_manager.start_scheduler()
@@ -3264,6 +3333,7 @@ async def lifespan(app: FastAPI):
# Shutdown
print_scheduler.stop()
await background_dispatch.stop()
smart_plug_manager.stop_scheduler()
notification_service.stop_digest_scheduler()
github_backup_service.stop_scheduler()
@@ -3297,7 +3367,8 @@ PUBLIC_API_ROUTES = {
"/api/v1/auth/status",
"/api/v1/auth/login",
"/api/v1/auth/setup", # Needed for initial setup and recovery
"/api/v1/auth/advanced-auth/status", # Advanced auth status needed for login page
# Advanced auth status needed for login page
"/api/v1/auth/advanced-auth/status",
"/api/v1/auth/forgot-password", # Password reset for advanced auth
# Version check for updates (no sensitive data)
"/api/v1/updates/version",
@@ -3449,6 +3520,7 @@ app.include_router(local_presets.router, prefix=app_settings.api_prefix)
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(print_log.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(background_dispatch_routes.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
+11 -5
View File
@@ -11,13 +11,15 @@ class ArchiveBase(BaseModel):
cost: float | None = None
failure_reason: str | None = None
quantity: int | None = None # Number of items printed
external_url: str | None = None # User-defined link (Printables, Thingiverse, etc.)
# User-defined link (Printables, Thingiverse, etc.)
external_url: str | None = None
class ArchiveUpdate(ArchiveBase):
printer_id: int | None = None
project_id: int | None = None
status: str | None = None # Allow changing status (e.g., clearing failed flag)
# Allow changing status (e.g., clearing failed flag)
status: str | None = None
class ArchiveDuplicate(BaseModel):
@@ -53,7 +55,8 @@ class ArchiveResponse(BaseModel):
print_name: str | None
print_time_seconds: int | None # Estimated time from slicer
actual_time_seconds: int | None = None # Computed from started_at/completed_at
time_accuracy: float | None = None # Percentage: 100 = perfect, >100 = faster than estimated
# Percentage: 100 = perfect, >100 = faster than estimated
time_accuracy: float | None = None
filament_used_grams: float | None
filament_type: str | None
filament_color: str | None
@@ -73,7 +76,8 @@ class ArchiveResponse(BaseModel):
makerworld_url: str | None
designer: str | None
external_url: str | None = None # User-defined link (Printables, Thingiverse, etc.)
# User-defined link (Printables, Thingiverse, etc.)
external_url: str | None = None
is_favorite: bool
tags: str | None
@@ -116,7 +120,8 @@ class ArchiveStats(BaseModel):
prints_by_filament_type: dict
prints_by_printer: dict
# Time accuracy stats
average_time_accuracy: float | None = None # Average across all prints with data
# Average across all prints with data
average_time_accuracy: float | None = None
time_accuracy_by_printer: dict | None = None # Per-printer accuracy
# Energy stats
total_energy_kwh: float = 0.0
@@ -181,6 +186,7 @@ class ReprintRequest(BaseModel):
# Plate selection for multi-plate 3MF files
# If not specified, auto-detects from file (legacy behavior for single-plate files)
plate_id: int | None = None
plate_name: str | None = None
# AMS slot mapping: list of tray IDs for each filament slot in the 3MF
# Global tray ID = (ams_id * 4) + slot_id, external = 254
+1
View File
@@ -181,6 +181,7 @@ class FilePrintRequest(BaseModel):
# Print options (same as archive reprint)
plate_id: int | None = None
plate_name: str | None = None
ams_mapping: list[int] | None = None
bed_levelling: bool = True
flow_cali: bool = False
+857
View File
@@ -0,0 +1,857 @@
"""Background dispatch for print/reprint jobs.
This service is separate from the app's print queue feature. It exists only to
decouple "send/start print" operations (FTP upload + start command) from API
request latency so the UI can continue immediately after dispatch.
"""
from __future__ import annotations
import asyncio
import logging
import time
import zipfile
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from sqlalchemy import select
from backend.app.core.config import settings
from backend.app.core.database import async_session
from backend.app.core.websocket import ws_manager
from backend.app.models.library import LibraryFile
from backend.app.models.printer import Printer
from backend.app.services.archive import ArchiveService
from backend.app.services.bambu_ftp import (
delete_file_async,
get_ftp_retry_settings,
upload_file_async,
with_ftp_retry,
)
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
class DispatchJobCancelled(Exception):
"""Raised when a dispatch job is cancelled by the user."""
class DispatchEnqueueRejected(Exception):
"""Raised when a dispatch job should not be accepted."""
@dataclass(slots=True)
class PrintDispatchJob:
id: int
kind: Literal["reprint_archive", "print_library_file"]
source_id: int
source_name: str
printer_id: int
printer_name: str
options: dict[str, Any] = field(default_factory=dict)
requested_by_user_id: int | None = None
requested_by_username: str | None = None
@dataclass(slots=True)
class ActiveDispatchState:
job: PrintDispatchJob
message: str
upload_bytes: int | None = None
upload_total_bytes: int | None = None
class BackgroundDispatchService:
def __init__(self):
self._queued_jobs: deque[PrintDispatchJob] = deque()
self._dispatcher_task: asyncio.Task | None = None
self._running_tasks: dict[int, asyncio.Task] = {}
self._lock = asyncio.Lock()
self._job_event = asyncio.Event()
self._next_job_id = 1
self._active_jobs: dict[int, ActiveDispatchState] = {}
self._cancel_requested_job_ids: set[int] = set()
# Progress for the current "batch" (since queue became non-empty)
self._batch_total = 0
self._batch_completed = 0
self._batch_failed = 0
@staticmethod
def _printer_is_busy_printing(printer_id: int) -> bool:
state = printer_manager.get_status(printer_id)
if not state:
return False
return state.state in ("RUNNING", "PAUSE", "PAUSED") and bool(state.gcode_file)
async def start(self):
async with self._lock:
if self._dispatcher_task and not self._dispatcher_task.done():
return
self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name="background-dispatch-dispatcher")
logger.info("Background dispatch dispatcher started")
async def stop(self):
dispatcher: asyncio.Task | None = None
running_tasks: list[asyncio.Task] = []
async with self._lock:
dispatcher = self._dispatcher_task
self._dispatcher_task = None
running_tasks = list(self._running_tasks.values())
self._running_tasks.clear()
self._active_jobs.clear()
self._queued_jobs.clear()
self._cancel_requested_job_ids.clear()
self._job_event.set()
if dispatcher:
dispatcher.cancel()
for task in running_tasks:
task.cancel()
if dispatcher:
try:
await dispatcher
except asyncio.CancelledError:
pass
if running_tasks:
await asyncio.gather(*running_tasks, return_exceptions=True)
logger.info("Background dispatch dispatcher stopped")
async def dispatch_reprint_archive(
self,
*,
archive_id: int,
archive_name: str,
printer_id: int,
printer_name: str,
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
) -> dict[str, Any]:
return await self._dispatch(
kind="reprint_archive",
source_id=archive_id,
source_name=archive_name,
printer_id=printer_id,
printer_name=printer_name,
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
)
async def get_state(self) -> dict[str, Any]:
"""Get current dispatch queue state snapshot for newly connected clients."""
async with self._lock:
return self._build_state_payload_unlocked()
async def dispatch_print_library_file(
self,
*,
file_id: int,
filename: str,
printer_id: int,
printer_name: str,
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
) -> dict[str, Any]:
return await self._dispatch(
kind="print_library_file",
source_id=file_id,
source_name=filename,
printer_id=printer_id,
printer_name=printer_name,
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
)
async def cancel_job(self, job_id: int) -> dict[str, Any]:
"""Cancel a queued dispatch job.
Queued jobs are removed immediately. Active jobs are cancelled
cooperatively and will stop at the next cancellation checkpoint.
"""
active_cancel_payload: dict[str, Any] | None = None
active_cancel_result: dict[str, Any] | None = None
async with self._lock:
active_state = self._active_jobs.get(job_id)
if active_state is not None:
logger.info("Cancel requested for active dispatch job %s", job_id)
self._cancel_requested_job_ids.add(job_id)
active_job = active_state.job
active_cancel_payload = self._build_state_payload_unlocked(
recent_event={
"status": "cancelling",
"job_id": active_job.id,
"source_name": active_job.source_name,
"printer_id": active_job.printer_id,
"printer_name": active_job.printer_name,
"message": "Cancelling current dispatch...",
}
)
active_cancel_result = {
"cancelled": True,
"pending": True,
"job_id": active_job.id,
"source_name": active_job.source_name,
"printer_id": active_job.printer_id,
"printer_name": active_job.printer_name,
}
if active_cancel_payload and active_cancel_result:
await ws_manager.broadcast({"type": "background_dispatch", "data": active_cancel_payload})
return active_cancel_result
async with self._lock:
cancelled_job: PrintDispatchJob | None = None
for job in self._queued_jobs:
if job.id == job_id:
cancelled_job = job
break
if not cancelled_job:
logger.info("Cancel requested for unknown dispatch job %s", job_id)
return {"cancelled": False, "reason": "not_found"}
self._queued_jobs.remove(cancelled_job)
logger.info("Cancelled queued dispatch job %s", cancelled_job.id)
self._batch_total = max(0, self._batch_total - 1)
if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
self._batch_completed = 0
self._batch_failed = 0
payload = self._build_state_payload_unlocked(
recent_event={
"status": "cancelled",
"job_id": cancelled_job.id,
"source_name": cancelled_job.source_name,
"printer_id": cancelled_job.printer_id,
"printer_name": cancelled_job.printer_name,
"message": "Cancelled from queue",
}
)
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
return {
"cancelled": True,
"pending": False,
"job_id": cancelled_job.id,
"source_name": cancelled_job.source_name,
"printer_id": cancelled_job.printer_id,
"printer_name": cancelled_job.printer_name,
}
async def _dispatch(
self,
*,
kind: Literal["reprint_archive", "print_library_file"],
source_id: int,
source_name: str,
printer_id: int,
printer_name: str,
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
) -> dict[str, Any]:
async with self._lock:
has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())
if has_pending_for_printer or has_active_for_printer:
raise DispatchEnqueueRejected(f"Printer {printer_name} already has a background dispatch in progress")
if self._printer_is_busy_printing(printer_id):
raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
job = PrintDispatchJob(
id=self._next_job_id,
kind=kind,
source_id=source_id,
source_name=source_name,
printer_id=printer_id,
printer_name=printer_name,
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
)
self._next_job_id += 1
self._batch_total += 1
self._queued_jobs.append(job)
self._job_event.set()
payload = self._build_state_payload_unlocked(
recent_event={
"status": "dispatched",
"job_id": job.id,
"source_name": source_name,
"printer_id": printer_id,
"printer_name": printer_name,
"message": f"Dispatched to {printer_name}",
}
)
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
return {
"dispatch_job_id": job.id,
"dispatch_position": dispatch_position,
"status": "dispatched",
"printer_id": printer_id,
"source_id": source_id,
"source_name": source_name,
}
async def _dispatcher_loop(self):
while True:
await self._job_event.wait()
self._job_event.clear()
while True:
payload: dict[str, Any] | None = None
job_to_start: PrintDispatchJob | None = None
async with self._lock:
busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}
start_index = next(
(
idx
for idx, queued_job in enumerate(self._queued_jobs)
if queued_job.printer_id not in busy_printer_ids
),
None,
)
if start_index is None:
break
job_to_start = self._queued_jobs[start_index]
del self._queued_jobs[start_index]
self._active_jobs[job_to_start.id] = ActiveDispatchState(
job=job_to_start,
message="Preparing background dispatch...",
)
task = asyncio.create_task(
self._run_active_job(job_to_start), name=f"background-dispatch-job-{job_to_start.id}"
)
self._running_tasks[job_to_start.id] = task
payload = self._build_state_payload_unlocked(
recent_event={
"status": "processing",
"job_id": job_to_start.id,
"source_name": job_to_start.source_name,
"printer_id": job_to_start.printer_id,
"printer_name": job_to_start.printer_name,
"message": "Preparing background dispatch...",
}
)
if payload:
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
async def _run_active_job(self, job: PrintDispatchJob):
try:
await self._process_job(job)
await self._mark_job_finished(job, failed=False, message="Background dispatch complete")
except DispatchJobCancelled:
await self._mark_job_cancelled(job)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("Background dispatch job %s failed: %s", job.id, e, exc_info=True)
await self._mark_job_finished(job, failed=True, message=str(e))
finally:
self._job_event.set()
async def _set_active_message(self, job: PrintDispatchJob, message: str):
async with self._lock:
active = self._active_jobs.get(job.id)
if not active:
return
active.message = message
payload = self._build_state_payload_unlocked(
recent_event={
"status": "processing",
"job_id": active.job.id,
"source_name": active.job.source_name,
"printer_id": active.job.printer_id,
"printer_name": active.job.printer_name,
"message": message,
}
)
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):
async with self._lock:
active = self._active_jobs.get(job.id)
if not active:
return
active.upload_bytes = max(0, int(uploaded))
active.upload_total_bytes = max(0, int(total))
payload = self._build_state_payload_unlocked(
recent_event={
"status": "processing",
"job_id": active.job.id,
"source_name": active.job.source_name,
"printer_id": active.job.printer_id,
"printer_name": active.job.printer_name,
"message": active.message,
}
)
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
async with self._lock:
if failed:
self._batch_failed += 1
else:
self._batch_completed += 1
self._active_jobs.pop(job.id, None)
self._running_tasks.pop(job.id, None)
self._cancel_requested_job_ids.discard(job.id)
payload = self._build_state_payload_unlocked(
recent_event={
"status": "failed" if failed else "completed",
"job_id": job.id,
"source_name": job.source_name,
"printer_id": job.printer_id,
"printer_name": job.printer_name,
"message": message,
}
)
should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
if should_reset_batch:
async with self._lock:
self._batch_total = 0
self._batch_completed = 0
self._batch_failed = 0
async def _mark_job_cancelled(self, job: PrintDispatchJob):
async with self._lock:
self._active_jobs.pop(job.id, None)
self._running_tasks.pop(job.id, None)
self._cancel_requested_job_ids.discard(job.id)
self._batch_total = max(0, self._batch_total - 1)
if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
self._batch_completed = 0
self._batch_failed = 0
payload = self._build_state_payload_unlocked(
recent_event={
"status": "cancelled",
"job_id": job.id,
"source_name": job.source_name,
"printer_id": job.printer_id,
"printer_name": job.printer_name,
"message": "Cancelled during dispatch",
}
)
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
def _is_cancel_requested(self, job_id: int) -> bool:
return job_id in self._cancel_requested_job_ids
def _raise_if_cancel_requested(self, job: PrintDispatchJob):
if self._is_cancel_requested(job.id):
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
processing = len(self._active_jobs)
dispatched = len(self._queued_jobs)
dispatched_jobs = [
{
"job_id": job.id,
"kind": job.kind,
"source_id": job.source_id,
"source_name": job.source_name,
"printer_id": job.printer_id,
"printer_name": job.printer_name,
}
for job in list(self._queued_jobs)
]
active_jobs: list[dict[str, Any]] = []
for active in self._active_jobs.values():
upload_progress_pct = None
if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:
upload_progress_pct = round(
max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1
)
active_jobs.append(
{
"job_id": active.job.id,
"kind": active.job.kind,
"source_id": active.job.source_id,
"source_name": active.job.source_name,
"printer_id": active.job.printer_id,
"printer_name": active.job.printer_name,
"message": active.message,
"upload_bytes": active.upload_bytes,
"upload_total_bytes": active.upload_total_bytes,
"upload_progress_pct": upload_progress_pct,
}
)
active_jobs.sort(key=lambda item: int(item["job_id"]))
active_job = active_jobs[0] if active_jobs else None
return {
"total": self._batch_total,
"dispatched": dispatched,
"processing": processing,
"completed": self._batch_completed,
"failed": self._batch_failed,
"dispatched_jobs": dispatched_jobs,
"active_jobs": active_jobs,
"active_job": active_job,
"recent_event": recent_event,
}
async def _process_job(self, job: PrintDispatchJob):
if job.kind == "reprint_archive":
await self._run_reprint_archive(job)
return
if job.kind == "print_library_file":
await self._run_print_library_file(job)
return
raise RuntimeError(f"Unknown dispatch job kind: {job.kind}")
async def _run_reprint_archive(self, job: PrintDispatchJob):
from backend.app.main import register_expected_print
async with async_session() as db:
service = ArchiveService(db)
archive = await service.get_archive(job.source_id)
if not archive:
raise RuntimeError("Archive not found")
printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
if not printer:
raise RuntimeError("Printer not found")
printer_name = printer.name
printer_ip = printer.ip_address
printer_access_code = printer.access_code
printer_model = printer.model
archive_filename = archive.filename
if not printer_manager.is_connected(job.printer_id):
raise RuntimeError("Printer is not connected")
file_path = settings.base_dir / archive.file_path
if not file_path.exists():
raise RuntimeError("Archive file not found")
base_name = archive.filename
if base_name.endswith(".gcode.3mf"):
base_name = base_name[:-10]
elif base_name.endswith(".3mf"):
base_name = base_name[:-4]
remote_filename = f"{base_name}.3mf"
remote_path = f"/{remote_filename}"
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
self._raise_if_cancel_requested(job)
await self._set_active_message(job, f"Preparing upload to {printer_name}...")
await delete_file_async(
printer_ip,
printer_access_code,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer_model,
)
self._raise_if_cancel_requested(job)
def upload_progress_callback(_uploaded: int, _total: int):
if self._is_cancel_requested(job.id):
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
try:
await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
loop = asyncio.get_running_loop()
progress_state = {"last_emit": 0.0, "last_bytes": 0}
def upload_progress_callback(uploaded: int, total: int):
if self._is_cancel_requested(job.id):
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
now = time.monotonic()
should_emit = (
uploaded >= total
or now - progress_state["last_emit"] >= 0.2
or uploaded - progress_state["last_bytes"] >= 256 * 1024
)
if should_emit:
progress_state["last_emit"] = now
progress_state["last_bytes"] = uploaded
loop.call_soon_threadsafe(
lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
)
if ftp_retry_enabled:
uploaded = await with_ftp_retry(
upload_file_async,
printer_ip,
printer_access_code,
file_path,
remote_path,
progress_callback=upload_progress_callback,
socket_timeout=ftp_timeout,
printer_model=printer_model,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Upload for reprint to {printer_name}",
non_retry_exceptions=(DispatchJobCancelled,),
)
else:
uploaded = await upload_file_async(
printer_ip,
printer_access_code,
file_path,
remote_path,
progress_callback=upload_progress_callback,
socket_timeout=ftp_timeout,
printer_model=printer_model,
)
if uploaded:
await self._set_active_upload_progress(job, 1, 1)
if not uploaded:
raise RuntimeError(
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
)
register_expected_print(job.printer_id, remote_filename, job.source_id)
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
self._raise_if_cancel_requested(job)
await self._set_active_message(job, f"Starting print on {printer_name}...")
started = printer_manager.start_print(
job.printer_id,
remote_filename,
plate_id,
ams_mapping=job.options.get("ams_mapping"),
timelapse=job.options.get("timelapse", False),
bed_levelling=job.options.get("bed_levelling", True),
flow_cali=job.options.get("flow_cali", False),
vibration_cali=job.options.get("vibration_cali", False),
layer_inspect=job.options.get("layer_inspect", False),
use_ams=job.options.get("use_ams", True),
)
if not started:
raise RuntimeError("Failed to start print")
if job.requested_by_user_id and job.requested_by_username:
printer_manager.set_current_print_user(
job.printer_id,
job.requested_by_user_id,
job.requested_by_username,
)
except DispatchJobCancelled:
await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
raise
async def _run_print_library_file(self, job: PrintDispatchJob):
from backend.app.main import register_expected_print
async with async_session() as db:
lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))
if not lib_file:
raise RuntimeError("File not found")
if not self._is_sliced_file(lib_file.filename):
raise RuntimeError("Not a sliced file. Only .gcode or .gcode.3mf files can be printed.")
file_path = Path(settings.base_dir) / lib_file.file_path
if not file_path.exists():
raise RuntimeError("File not found on disk")
printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
if not printer:
raise RuntimeError("Printer not found")
printer_name = printer.name
printer_ip = printer.ip_address
printer_access_code = printer.access_code
printer_model = printer.model
library_filename = lib_file.filename
if not printer_manager.is_connected(job.printer_id):
raise RuntimeError("Printer is not connected")
await self._set_active_message(job, f"Creating archive for {lib_file.filename}...")
archive_service = ArchiveService(db)
archive = await archive_service.archive_print(
printer_id=job.printer_id,
source_file=file_path,
)
if not archive:
raise RuntimeError("Failed to create archive")
await db.flush()
base_name = lib_file.filename
if base_name.endswith(".gcode.3mf"):
base_name = base_name[:-10]
elif base_name.endswith(".3mf"):
base_name = base_name[:-4]
remote_filename = f"{base_name}.3mf"
remote_path = f"/{remote_filename}"
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
self._raise_if_cancel_requested(job)
await self._set_active_message(job, f"Preparing upload to {printer_name}...")
await delete_file_async(
printer_ip,
printer_access_code,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer_model,
)
self._raise_if_cancel_requested(job)
def upload_progress_callback(_uploaded: int, _total: int):
if self._is_cancel_requested(job.id):
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
try:
await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
loop = asyncio.get_running_loop()
progress_state = {"last_emit": 0.0, "last_bytes": 0}
def upload_progress_callback(uploaded: int, total: int):
if self._is_cancel_requested(job.id):
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
now = time.monotonic()
should_emit = (
uploaded >= total
or now - progress_state["last_emit"] >= 0.2
or uploaded - progress_state["last_bytes"] >= 256 * 1024
)
if should_emit:
progress_state["last_emit"] = now
progress_state["last_bytes"] = uploaded
loop.call_soon_threadsafe(
lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
)
if ftp_retry_enabled:
uploaded = await with_ftp_retry(
upload_file_async,
printer_ip,
printer_access_code,
file_path,
remote_path,
progress_callback=upload_progress_callback,
socket_timeout=ftp_timeout,
printer_model=printer_model,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Upload for print to {printer_name}",
non_retry_exceptions=(DispatchJobCancelled,),
)
else:
uploaded = await upload_file_async(
printer_ip,
printer_access_code,
file_path,
remote_path,
progress_callback=upload_progress_callback,
socket_timeout=ftp_timeout,
printer_model=printer_model,
)
if uploaded:
await self._set_active_upload_progress(job, 1, 1)
if not uploaded:
await db.rollback()
raise RuntimeError(
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
)
register_expected_print(job.printer_id, remote_filename, archive.id)
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
self._raise_if_cancel_requested(job)
await self._set_active_message(job, f"Starting print on {printer_name}...")
started = printer_manager.start_print(
job.printer_id,
remote_filename,
plate_id,
ams_mapping=job.options.get("ams_mapping"),
timelapse=job.options.get("timelapse", False),
bed_levelling=job.options.get("bed_levelling", True),
flow_cali=job.options.get("flow_cali", False),
vibration_cali=job.options.get("vibration_cali", False),
layer_inspect=job.options.get("layer_inspect", False),
use_ams=job.options.get("use_ams", True),
)
if not started:
await db.rollback()
raise RuntimeError("Failed to start print")
await db.commit()
except DispatchJobCancelled:
await db.rollback()
await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
raise
@staticmethod
def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:
if requested_plate_id is not None:
return requested_plate_id
plate_id = 1
try:
with zipfile.ZipFile(file_path, "r") as zf:
for name in zf.namelist():
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
plate_str = name[15:-6]
plate_id = int(plate_str)
break
except (ValueError, zipfile.BadZipFile, OSError):
pass
return plate_id
@staticmethod
def _is_sliced_file(filename: str) -> bool:
lower = filename.lower()
return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
background_dispatch = BackgroundDispatchService()
+60 -12
View File
@@ -76,13 +76,16 @@ class BambuFTPClient:
"""FTP client for retrieving files from Bambu Lab printers."""
FTP_PORT = 990
DEFAULT_TIMEOUT = 30 # Default timeout in seconds (increased for A1 printers)
# Default timeout in seconds (increased for A1 printers)
DEFAULT_TIMEOUT = 30
# Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
# These models have varying FTP SSL behavior depending on firmware version
A1_MODELS = ("A1", "A1 Mini")
# Chunk size for manual upload transfer (1MB)
# Larger chunks reduce overhead and work better with A1 printers
CHUNK_SIZE = 1024 * 1024
# Chunk size for manual upload transfer (256KB)
# Smaller chunks increase cancellation responsiveness during uploads.
CHUNK_SIZE = 256 * 1024
# Per-chunk data socket timeout during upload.
UPLOAD_CHUNK_TIMEOUT = 15
# Cache for working FTP modes per printer IP
# Maps IP -> "prot_p" or "prot_c"
@@ -359,6 +362,8 @@ class BambuFTPClient:
logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
uploaded = 0
callback_exception: Exception | None = None
transfer_response_ok = True
# Use manual transfer instead of storbinary() for A1 compatibility
# A1 printers have issues with storbinary's voidresp() hanging after transfer
@@ -368,7 +373,7 @@ class BambuFTPClient:
# Set explicit socket options for reliable transfer
conn.setblocking(True)
conn.settimeout(120) # 2 minute timeout per chunk
conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
try:
while True:
@@ -382,14 +387,51 @@ class BambuFTPClient:
logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
if progress_callback:
progress_callback(uploaded, file_size)
try:
progress_callback(uploaded, file_size)
except Exception as e:
callback_exception = e
logger.info(
"FTP upload callback requested stop for %s at %s/%s bytes: %s",
remote_path,
uploaded,
file_size,
e,
)
break
except OSError as e:
logger.error("FTP connection lost during upload: %s", e)
conn.close()
raise
finally:
try:
conn.close()
except OSError:
pass
conn.close()
try:
self._ftp.voidresp()
except (OSError, ftplib.Error) as e:
transfer_response_ok = False
logger.debug("FTP upload final response for %s was not clean: %s", remote_path, e)
if callback_exception is not None:
cleanup_ok = False
try:
cleanup_ok = self.delete_file(remote_path)
except Exception as cleanup_error:
logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
if cleanup_ok:
logger.info("FTP cancel cleanup succeeded for %s", remote_path)
raise callback_exception
raise RuntimeError(
f"Upload cancelled but failed to remove partial file {remote_path} from printer"
) from callback_exception
if not transfer_response_ok:
return False
logger.info("FTP upload complete: %s", remote_path)
return True
@@ -421,7 +463,7 @@ class BambuFTPClient:
# Use manual transfer instead of storbinary() for A1 compatibility
conn = self._ftp.transfercmd(f"STOR {remote_path}")
conn.setblocking(True)
conn.settimeout(120)
conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
try:
# Send data in chunks
@@ -432,10 +474,12 @@ class BambuFTPClient:
offset += len(chunk)
except OSError as e:
logger.error("FTP connection lost during upload_bytes: %s", e)
conn.close()
raise
conn.close()
finally:
try:
conn.close()
except OSError:
pass
return True
except (OSError, ftplib.Error):
return False
@@ -827,6 +871,7 @@ async def with_ftp_retry(
max_retries: int = 3,
retry_delay: float = 2.0,
operation_name: str = "FTP operation",
non_retry_exceptions: tuple[type[BaseException], ...] = (),
**kwargs,
) -> T | None:
"""Execute FTP operation with retry logic.
@@ -837,6 +882,7 @@ async def with_ftp_retry(
max_retries: Number of retry attempts (default: 3)
retry_delay: Seconds to wait between retries (default: 2.0)
operation_name: Name for logging purposes
non_retry_exceptions: Exception types that should immediately abort retries
**kwargs: Keyword arguments for the operation
Returns:
@@ -856,6 +902,8 @@ async def with_ftp_retry(
if attempt > 0:
logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
except Exception as e:
if non_retry_exceptions and isinstance(e, non_retry_exceptions):
raise
last_error = e
logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
@@ -0,0 +1,243 @@
"""Integration tests for background dispatch API behavior."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from backend.app.services.background_dispatch import DispatchEnqueueRejected
class TestBackgroundDispatchArchivesAPI:
"""Tests for archive reprint dispatch endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reprint_returns_dispatched_payload(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
):
"""Reprint endpoint returns background dispatch metadata."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename="widget.gcode.3mf",
file_path="archives/test/widget.gcode.3mf",
)
archive_file = tmp_path / archive.file_path
archive_file.parent.mkdir(parents=True, exist_ok=True)
archive_file.write_bytes(b"3mf-data")
with (
patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
) as mock_dispatch,
):
response = await async_client.post(
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
json={"plate_id": 2},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "dispatched"
assert data["dispatch_job_id"] == 15
assert data["dispatch_position"] == 1
assert data["filename"] == "widget.gcode.3mf"
mock_dispatch.assert_awaited_once()
kwargs = mock_dispatch.await_args.kwargs
assert kwargs["archive_name"].endswith("• Plate 2")
assert kwargs["options"]["plate_id"] == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reprint_returns_409_when_enqueue_rejected(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
):
"""Reprint endpoint maps enqueue rejection to HTTP 409."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename="widget2.gcode.3mf",
file_path="archives/test/widget2.gcode.3mf",
)
archive_file = tmp_path / archive.file_path
archive_file.parent.mkdir(parents=True, exist_ok=True)
archive_file.write_bytes(b"3mf-data")
with (
patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
),
):
response = await async_client.post(
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
json={"plate_id": 1},
)
assert response.status_code == 409
assert "already has a background dispatch" in response.json()["detail"]
class TestBackgroundDispatchLibraryAPI:
"""Tests for library print dispatch endpoint."""
@pytest.fixture
async def library_file_factory(self, db_session):
"""Factory to create library files."""
async def _create_file(**kwargs):
from backend.app.models.library import LibraryFile
defaults = {
"filename": "library_part.gcode.3mf",
"file_path": "library/files/library_part.gcode.3mf",
"file_type": "gcode",
"file_size": 1024,
}
defaults.update(kwargs)
lib_file = LibraryFile(**defaults)
db_session.add(lib_file)
await db_session.commit()
await db_session.refresh(lib_file)
return lib_file
return _create_file
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_print_returns_dispatched_payload(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Library print endpoint returns dispatch job metadata."""
printer = await printer_factory()
lib_file = await library_file_factory()
disk_path = tmp_path / lib_file.file_path
disk_path.parent.mkdir(parents=True, exist_ok=True)
disk_path.write_bytes(b"library data")
with (
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
) as mock_dispatch,
):
response = await async_client.post(
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
json={"plate_id": 4},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "dispatched"
assert data["dispatch_job_id"] == 21
assert data["dispatch_position"] == 2
assert data["archive_id"] is None
mock_dispatch.assert_awaited_once()
kwargs = mock_dispatch.await_args.kwargs
assert kwargs["filename"].endswith("• Plate 4")
assert kwargs["options"]["plate_id"] == 4
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_print_returns_409_when_enqueue_rejected(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Library print endpoint maps enqueue rejection to HTTP 409."""
printer = await printer_factory()
lib_file = await library_file_factory(filename="another_part.gcode")
disk_path = tmp_path / lib_file.file_path
disk_path.parent.mkdir(parents=True, exist_ok=True)
disk_path.write_bytes(b"library data")
with (
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
),
):
response = await async_client.post(
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
json={"plate_id": 1},
)
assert response.status_code == 409
assert "queue conflict" in response.json()["detail"]
class TestBackgroundDispatchCancelAPI:
"""Tests for /background-dispatch cancel endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
"""Cancel endpoint returns cancelled for queued job."""
with patch(
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
new=AsyncMock(
return_value={
"cancelled": True,
"pending": False,
"job_id": 9,
"source_name": "cube.gcode.3mf",
"printer_id": 1,
"printer_name": "Printer A",
}
),
):
response = await async_client.delete("/api/v1/background-dispatch/9")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled"
assert data["job_id"] == 9
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
"""Cancel endpoint returns cancelling while active upload is being interrupted."""
with patch(
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
new=AsyncMock(
return_value={
"cancelled": True,
"pending": True,
"job_id": 10,
"source_name": "cube.gcode.3mf",
"printer_id": 1,
"printer_name": "Printer A",
}
),
):
response = await async_client.delete("/api/v1/background-dispatch/10")
assert response.status_code == 200
assert response.json()["status"] == "cancelling"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
"""Cancel endpoint returns 404 for unknown job id."""
with patch(
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
):
response = await async_client.delete("/api/v1/background-dispatch/999")
assert response.status_code == 404
assert response.json()["detail"] == "Dispatch job not found"
@@ -0,0 +1,158 @@
"""Unit tests for background dispatch service."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.background_dispatch import (
ActiveDispatchState,
BackgroundDispatchService,
DispatchEnqueueRejected,
PrintDispatchJob,
)
@pytest.mark.asyncio
async def test_dispatch_rejects_when_printer_busy_printing():
"""Reject enqueue when target printer is already printing."""
service = BackgroundDispatchService()
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
),
pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
):
await service.dispatch_reprint_archive(
archive_id=1,
archive_name="Test Archive",
printer_id=10,
printer_name="Printer A",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
@pytest.mark.asyncio
async def test_dispatch_enqueues_job_and_broadcasts_state():
"""Enqueue succeeds and emits websocket queue update."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast,
):
result = await service.dispatch_print_library_file(
file_id=22,
filename="cube.gcode.3mf",
printer_id=7,
printer_name="Printer B",
options={"plate_id": 2},
requested_by_user_id=5,
requested_by_username="tester",
)
assert result["status"] == "dispatched"
assert result["dispatch_job_id"] == 1
assert result["dispatch_position"] == 1
assert len(service._queued_jobs) == 1
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["type"] == "background_dispatch"
assert payload["data"]["recent_event"]["status"] == "dispatched"
@pytest.mark.asyncio
async def test_cancel_queued_job_removes_it_and_broadcasts():
"""Cancelling queued job removes it immediately."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast,
):
result = await service.dispatch_reprint_archive(
archive_id=1,
archive_name="benchy.gcode.3mf",
printer_id=1,
printer_name="Printer 1",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
mock_broadcast.reset_mock()
cancel_result = await service.cancel_job(result["dispatch_job_id"])
assert cancel_result["cancelled"] is True
assert cancel_result["pending"] is False
assert len(service._queued_jobs) == 0
assert service._batch_total == 0
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
"""Cancelling active job marks it as pending cancellation."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=42,
kind="reprint_archive",
source_id=100,
source_name="gearbox.gcode.3mf",
printer_id=3,
printer_name="Printer C",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast:
result = await service.cancel_job(job.id)
assert result["cancelled"] is True
assert result["pending"] is True
assert job.id in service._cancel_requested_job_ids
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelling"
def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
"""Explicit plate_id wins over auto-detection."""
file_path = tmp_path / "dummy.3mf"
file_path.write_text("not-a-zip")
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
assert plate_id == 9
def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
"""Auto-detect plate from Metadata/plate_X.gcode entry."""
import zipfile
file_path = tmp_path / "multi.3mf"
with zipfile.ZipFile(file_path, "w") as zf:
zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
assert plate_id == 7
def test_is_sliced_file_recognizes_supported_extensions():
"""Only .gcode and .gcode.3mf should be accepted."""
assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
+23 -2
View File
@@ -1630,6 +1630,15 @@ export interface NotificationTestResponse {
message: string;
}
export interface BackgroundDispatchResponse {
status: 'dispatched' | string;
printer_id: number;
archive_id?: number | null;
filename: string;
dispatch_job_id: number;
dispatch_position: number;
}
// Provider-specific config types for reference
export interface CallMeBotConfig {
phone: string;
@@ -2937,6 +2946,7 @@ export const api = {
printerId: number,
options?: {
plate_id?: number;
plate_name?: string;
ams_mapping?: number[];
timelapse?: boolean;
bed_levelling?: boolean;
@@ -2946,7 +2956,7 @@ export const api = {
use_ams?: boolean;
}
) =>
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
request<BackgroundDispatchResponse>(
`/archives/${archiveId}/reprint?printer_id=${printerId}`,
{
method: 'POST',
@@ -4008,6 +4018,7 @@ export const api = {
printerId: number,
options?: {
plate_id?: number;
plate_name?: string;
ams_mapping?: number[];
bed_levelling?: boolean;
flow_cali?: boolean;
@@ -4017,13 +4028,23 @@ export const api = {
use_ams?: boolean;
}
) =>
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
request<BackgroundDispatchResponse>(
`/library/files/${fileId}/print?printer_id=${printerId}`,
{
method: 'POST',
body: options ? JSON.stringify(options) : undefined,
}
),
cancelBackgroundDispatchJob: (jobId: number) =>
request<{
status: 'cancelled' | 'cancelling';
job_id: number;
source_name: string;
printer_id: number;
printer_name: string;
}>(`/background-dispatch/${jobId}`, {
method: 'DELETE',
}),
getLibraryFilePlates: (fileId: number) =>
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
+25 -14
View File
@@ -1,27 +1,27 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
import { Card, CardContent } from '../Card';
import { Button } from '../Button';
import { api } from '../../api/client';
import { useToast } from '../../contexts/ToastContext';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import { isPlaceholderDate } from '../../utils/amsHelpers';
import { toDateTimeLocalValue } from '../../utils/date';
import { PrinterSelector } from './PrinterSelector';
import { PlateSelector } from './PlateSelector';
import { Button } from '../Button';
import { Card, CardContent } from '../Card';
import { FilamentMapping } from './FilamentMapping';
import { PlateSelector } from './PlateSelector';
import { PrinterSelector } from './PrinterSelector';
import { PrintOptionsPanel } from './PrintOptions';
import { ScheduleOptionsPanel } from './ScheduleOptions';
import type {
AssignmentMode,
PrintModalProps,
PrintOptions,
ScheduleOptions,
ScheduleType,
AssignmentMode,
} from './types';
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
@@ -230,6 +230,12 @@ export function PrintModal({
// Combine filament requirements from either source
const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
const selectedPlateName = useMemo(() => {
if (selectedPlate === null || !platesData?.plates?.length) {
return undefined;
}
return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
}, [platesData, selectedPlate]);
// Only fetch printer status when single printer selected (for filament mapping)
const { data: printerStatus } = useQuery({
@@ -450,12 +456,15 @@ export function PrintModal({
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
plate_id: selectedPlate ?? undefined,
plate_name: selectedPlateName,
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
plate_name: selectedPlateName,
ams_mapping: printerMapping,
...printOptions,
});
@@ -498,11 +507,12 @@ export function PrintModal({
if (assignmentMode === 'model') {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
} else {
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
if (mode === 'edit-queue-item') {
showToast('Queue item updated');
} else if (results.success === 1) {
showToast('Print queued for printer');
} else {
showToast(`Print ${action} ${results.success} printers`);
showToast(`Print queued for ${results.success} printers`);
}
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
@@ -743,4 +753,5 @@ export function PrintModal({
}
// Re-export types for convenience
export type { PrintModalProps, PrintModalMode } from './types';
export type { PrintModalMode, PrintModalProps } from './types';
+440 -11
View File
@@ -1,5 +1,7 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react';
import { CheckCircle, XCircle, AlertCircle, Info, X, Loader2 } from 'lucide-react';
import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
@@ -8,6 +10,29 @@ interface Toast {
message: string;
type: ToastType;
persistent?: boolean;
dispatchData?: DispatchToastData;
}
type DispatchJobStatus = 'dispatched' | 'processing' | 'completed' | 'failed' | 'cancelled';
interface DispatchToastJob {
jobId: number;
sourceName: string;
printerName: string;
status: DispatchJobStatus;
message?: string;
uploadBytes?: number;
uploadTotalBytes?: number;
uploadProgressPct?: number;
}
interface DispatchToastData {
total: number;
dispatched: number;
processing: number;
completed: number;
failed: number;
jobs: DispatchToastJob[];
}
interface ToastContextType {
@@ -43,8 +68,21 @@ const bgColors = {
};
export function ToastProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [toasts, setToasts] = useState<Toast[]>([]);
const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const dispatchToastId = 'background-dispatch';
const lastDispatchSummaryRef = useRef<string | null>(null);
const formatBytes = useCallback((bytes: number) => {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}, []);
// Clean up all timeouts on unmount
useEffect(() => {
@@ -88,6 +126,279 @@ export function ToastProvider({ children }: { children: ReactNode }) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const cancelDispatchJob = useCallback(async (jobId: number) => {
setCancellingDispatchJobIds((prev) => {
const next = new Set(prev);
next.add(jobId);
return next;
});
try {
const result = await api.cancelBackgroundDispatchJob(jobId);
showToast(
result.status === 'cancelling'
? t('backgroundDispatch.toast.cancellingUpload')
: t('backgroundDispatch.toast.cancelled'),
'info'
);
} catch (error) {
const message = error instanceof Error ? error.message : t('backgroundDispatch.toast.cancelFailed');
showToast(message, 'error');
} finally {
setCancellingDispatchJobIds((prev) => {
const next = new Set(prev);
next.delete(jobId);
return next;
});
}
}, [showToast, t]);
useEffect(() => {
interface DispatchEventDetail {
total?: number;
dispatched?: number;
processing?: number;
completed?: number;
failed?: number;
dispatched_jobs?: Array<{
job_id: number;
source_name?: string;
printer_name?: string;
}>;
active_job?: {
job_id?: number;
printer_name?: string;
source_name?: string;
message?: string;
upload_bytes?: number;
upload_total_bytes?: number;
upload_progress_pct?: number;
} | null;
active_jobs?: Array<{
job_id?: number;
printer_name?: string;
source_name?: string;
message?: string;
upload_bytes?: number;
upload_total_bytes?: number;
upload_progress_pct?: number;
}>;
recent_event?: {
status?: string;
job_id?: number;
source_name?: string;
printer_name?: string;
message?: string;
};
}
const updateJob = (
jobs: DispatchToastJob[],
jobId: number,
next: Partial<DispatchToastJob> & {
status: DispatchJobStatus;
sourceName: string;
printerName: string;
}
) => {
const index = jobs.findIndex((job) => job.jobId === jobId);
if (index === -1) {
return [...jobs, { jobId, ...next }];
}
const copy = [...jobs];
copy[index] = {
...copy[index],
...next,
};
return copy;
};
const statusWeight = (status: DispatchJobStatus) => {
switch (status) {
case 'failed':
return 0;
case 'processing':
return 1;
case 'dispatched':
return 2;
case 'completed':
return 3;
case 'cancelled':
return 4;
}
};
const onDispatchEvent = (event: Event) => {
const detail = (event as CustomEvent<DispatchEventDetail>).detail || {};
const total = detail.total ?? 0;
const dispatched = detail.dispatched ?? 0;
const processing = detail.processing ?? 0;
const completed = detail.completed ?? 0;
const failed = detail.failed ?? 0;
const hasActiveWork = dispatched + processing > 0;
const allDone = total > 0 && completed + failed >= total && !hasActiveWork;
if (hasActiveWork) {
setToasts((prev) => {
const existing = prev.find((t) => t.id === dispatchToastId);
const existingJobs = existing?.dispatchData?.jobs || [];
const dispatchedJobs: DispatchToastJob[] = (detail.dispatched_jobs || []).map((job) => ({
jobId: job.job_id,
sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
status: 'dispatched',
}));
const activeJobsPayload =
detail.active_jobs && detail.active_jobs.length > 0
? detail.active_jobs
: detail.active_job?.job_id
? [detail.active_job]
: [];
const activeJobs: DispatchToastJob[] = activeJobsPayload
.filter((job) => typeof job.job_id === 'number')
.map((job) => ({
jobId: job.job_id as number,
sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
status: 'processing',
message: job.message,
uploadBytes: job.upload_bytes,
uploadTotalBytes: job.upload_total_bytes,
uploadProgressPct: job.upload_progress_pct,
}));
const activeIds = new Set([...dispatchedJobs, ...activeJobs].map((job) => job.jobId));
const historicalJobs = existingJobs.filter(
(job) => !activeIds.has(job.jobId) && ['completed', 'failed', 'cancelled'].includes(job.status)
);
let jobs = [...dispatchedJobs, ...activeJobs, ...historicalJobs];
if (detail.recent_event?.job_id && detail.recent_event?.status) {
const rawStatus = detail.recent_event.status;
const eventStatus = (
rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'processing' : rawStatus
) as DispatchJobStatus;
const sourceName = detail.recent_event.source_name || t('backgroundDispatch.unknownFile');
const printerName = detail.recent_event.printer_name || t('backgroundDispatch.unknownPrinter');
jobs = updateJob(jobs, detail.recent_event.job_id, {
status: eventStatus,
sourceName,
printerName,
message: detail.recent_event.message,
});
}
activeJobs.forEach((activeJob) => {
jobs = updateJob(jobs, activeJob.jobId, {
status: 'processing',
sourceName: activeJob.sourceName,
printerName: activeJob.printerName,
message: activeJob.message,
uploadBytes: activeJob.uploadBytes,
uploadTotalBytes: activeJob.uploadTotalBytes,
uploadProgressPct: activeJob.uploadProgressPct,
});
});
const dispatchData: DispatchToastData = {
total,
dispatched,
processing,
completed,
failed,
jobs: [...jobs].sort((a, b) => {
const byStatus = statusWeight(a.status) - statusWeight(b.status);
if (byStatus !== 0) {
return byStatus;
}
return a.jobId - b.jobId;
}),
};
const exists = prev.find((t) => t.id === dispatchToastId);
if (exists) {
return prev.map((t) =>
t.id === dispatchToastId
? {
...t,
message: t('backgroundDispatch.startingPrints'),
type: 'loading',
persistent: true,
dispatchData,
}
: t
);
}
return [
...prev,
{
id: dispatchToastId,
message: t('backgroundDispatch.startingPrints'),
type: 'loading',
persistent: true,
dispatchData,
},
];
});
return;
}
const recentStatus = detail.recent_event?.status;
if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {
setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
}
if (allDone) {
const summaryKey = `${completed}:${failed}`;
if (lastDispatchSummaryRef.current === summaryKey) {
return;
}
lastDispatchSummaryRef.current = summaryKey;
setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
const doneMessage = failed > 0
? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
: t('backgroundDispatch.toast.completeSuccess', { completed });
const id = Math.random().toString(36).substr(2, 9);
setToasts((prev) => [...prev, { id, message: doneMessage, type: failed > 0 ? 'warning' : 'success' }]);
const timeout = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timeoutRefs.current.delete(id);
}, 4000);
timeoutRefs.current.set(id, timeout);
}
if (detail.recent_event?.status === 'idle' && !hasActiveWork) {
setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
}
if (!hasActiveWork) {
setCancellingDispatchJobIds(new Set());
}
if (detail.dispatched_jobs) {
const dispatchedIds = new Set(detail.dispatched_jobs.map((job) => job.job_id));
setCancellingDispatchJobIds((prev) => {
const next = new Set<number>();
prev.forEach((id) => {
if (dispatchedIds.has(id)) {
next.add(id);
}
});
return next;
});
}
};
window.addEventListener('background-dispatch', onDispatchEvent);
return () => window.removeEventListener('background-dispatch', onDispatchEvent);
}, [t]);
return (
<ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
{children}
@@ -97,16 +408,134 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{toasts.map((toast) => (
<div
key={toast.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]}`}
className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
}`}
>
{icons[toast.type]}
<span className="text-white text-sm">{toast.message}</span>
<button
onClick={() => dismissToast(toast.id)}
className="ml-2 text-bambu-gray hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
{toast.dispatchData ? (
<>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
{icons[toast.type]}
<div>
<p className="text-white text-sm font-medium">{t('backgroundDispatch.startingPrints')}</p>
<p className="text-xs text-bambu-gray mt-0.5">
{t('backgroundDispatch.progressSummary', {
complete: toast.dispatchData.completed + toast.dispatchData.failed,
total: toast.dispatchData.total,
dispatched: toast.dispatchData.dispatched,
processing: toast.dispatchData.processing,
})}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsDispatchCollapsed((prev) => !prev)}
className="text-bambu-gray hover:text-white transition-colors"
aria-label={
isDispatchCollapsed
? t('backgroundDispatch.expandDetails')
: t('backgroundDispatch.collapseDetails')
}
>
{isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => dismissToast(toast.id)}
className="text-bambu-gray hover:text-white transition-colors"
aria-label={t('backgroundDispatch.dismissToast')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{!isDispatchCollapsed && (
<div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
{toast.dispatchData.jobs.map((job) => {
const progressByStatus: Record<DispatchJobStatus, number> = {
dispatched: 15,
processing: 60,
completed: 100,
failed: 100,
cancelled: 100,
};
const barColorByStatus: Record<DispatchJobStatus, string> = {
dispatched: 'bg-bambu-gray/60',
processing: 'bg-bambu-green',
completed: 'bg-green-500',
failed: 'bg-red-500',
cancelled: 'bg-yellow-500',
};
return (
<div key={job.jobId} className="rounded border border-white/10 bg-black/15 p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-white truncate" title={job.sourceName}>
{job.sourceName}
</span>
<div className="flex items-center gap-2">
{(job.status === 'dispatched' || job.status === 'processing') && (
<button
onClick={() => void cancelDispatchJob(job.jobId)}
disabled={cancellingDispatchJobIds.has(job.jobId)}
className="text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
title={t('backgroundDispatch.cancelDispatchJob')}
>
{cancellingDispatchJobIds.has(job.jobId)
? t('backgroundDispatch.cancelling')
: t('backgroundDispatch.cancel')}
</button>
)}
<span className="text-[11px] uppercase tracking-wide text-bambu-gray">
{t(`backgroundDispatch.status.${job.status}`)}
</span>
</div>
</div>
<div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
{job.printerName}
</div>
{job.message && (
<div className="text-[11px] text-bambu-gray truncate" title={job.message}>
{job.message}
</div>
)}
{job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
<div className="text-[11px] text-bambu-gray truncate">
{formatBytes(job.uploadBytes)} / {formatBytes(job.uploadTotalBytes)}
{typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
</div>
)}
<div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
<div
className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}
style={{
width: `${
job.status === 'processing' && typeof job.uploadProgressPct === 'number'
? Math.max(0, Math.min(100, job.uploadProgressPct))
: progressByStatus[job.status]
}%`,
}}
/>
</div>
</div>
);
})}
</div>
)}
</>
) : (
<>
{icons[toast.type]}
<span className="text-white text-sm">{toast.message}</span>
<button
onClick={() => dismissToast(toast.id)}
className="ml-2 text-bambu-gray hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</>
)}
</div>
))}
</div>
+8 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useState } from 'react';
interface WebSocketMessage {
type: string;
@@ -249,6 +249,13 @@ export function useWebSocket() {
tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
}
}));
case 'background_dispatch':
window.dispatchEvent(
new CustomEvent('background-dispatch', {
detail: (message as unknown as { data?: Record<string, unknown> }).data || {},
})
);
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
+27
View File
@@ -890,6 +890,33 @@ export default {
},
},
backgroundDispatch: {
unknownFile: 'Unbekannte Datei',
unknownPrinter: 'Unbekannter Drucker',
startingPrints: 'Starte Drucke',
progressSummary: '{{complete}}/{{total}} abgeschlossen • Geplant: {{dispatched}} • In Bearbeitung: {{processing}}',
expandDetails: 'Dispatch-Details ausklappen',
collapseDetails: 'Dispatch-Details einklappen',
dismissToast: 'Dispatch-Hinweis schließen',
cancelDispatchJob: 'Dispatch-Job abbrechen',
cancel: 'Abbrechen',
cancelling: 'Wird abgebrochen…',
status: {
dispatched: 'Geplant',
processing: 'In Bearbeitung',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
cancelled: 'Abgebrochen',
},
toast: {
cancellingUpload: 'Upload wird abgebrochen...',
cancelled: 'Dispatch abgebrochen',
cancelFailed: 'Dispatch konnte nicht abgebrochen werden',
completeWithFailures: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich, {{failed}} fehlgeschlagen',
completeSuccess: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich',
},
},
// Statistics page
stats: {
title: 'Dashboard',
+27
View File
@@ -890,6 +890,33 @@ export default {
},
},
backgroundDispatch: {
unknownFile: 'Unknown file',
unknownPrinter: 'Unknown printer',
startingPrints: 'Starting prints',
progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
expandDetails: 'Expand dispatch details',
collapseDetails: 'Collapse dispatch details',
dismissToast: 'Dismiss dispatch toast',
cancelDispatchJob: 'Cancel dispatch job',
cancel: 'Cancel',
cancelling: 'Cancelling…',
status: {
dispatched: 'Dispatched',
processing: 'Processing',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
},
toast: {
cancellingUpload: 'Cancelling upload...',
cancelled: 'Dispatch cancelled',
cancelFailed: 'Failed to cancel dispatch',
completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
},
},
// Statistics page
stats: {
title: 'Dashboard',
+27
View File
@@ -869,6 +869,33 @@ export default {
},
},
backgroundDispatch: {
unknownFile: 'File sconosciuto',
unknownPrinter: 'Stampante sconosciuta',
startingPrints: 'Avvio stampe',
progressSummary: '{{complete}}/{{total}} completati • Inviati: {{dispatched}} • In elaborazione: {{processing}}',
expandDetails: 'Espandi dettagli dispatch',
collapseDetails: 'Comprimi dettagli dispatch',
dismissToast: 'Chiudi notifica dispatch',
cancelDispatchJob: 'Annulla job dispatch',
cancel: 'Annulla',
cancelling: 'Annullamento…',
status: {
dispatched: 'Inviato',
processing: 'In elaborazione',
completed: 'Completato',
failed: 'Fallito',
cancelled: 'Annullato',
},
toast: {
cancellingUpload: 'Annullamento upload...',
cancelled: 'Dispatch annullato',
cancelFailed: 'Impossibile annullare il dispatch',
completeWithFailures: 'Dispatch in background completato: {{completed}} riusciti, {{failed}} falliti',
completeSuccess: 'Dispatch in background completato: {{completed}} riusciti',
},
},
// Statistics page
stats: {
title: 'Dashboard',
+26
View File
@@ -957,6 +957,32 @@ export default {
noCancelItems: 'キューアイテムをキャンセルする権限がありません',
},
},
backgroundDispatch: {
unknownFile: '不明なファイル',
unknownPrinter: '不明なプリンター',
startingPrints: '印刷開始中',
progressSummary: '{{complete}}/{{total}} 完了 • 配信済み: {{dispatched}} • 処理中: {{processing}}',
expandDetails: '配信詳細を展開',
collapseDetails: '配信詳細を折りたたむ',
dismissToast: '配信トーストを閉じる',
cancelDispatchJob: '配信ジョブをキャンセル',
cancel: 'キャンセル',
cancelling: 'キャンセル中…',
status: {
dispatched: '配信済み',
processing: '処理中',
completed: '完了',
failed: '失敗',
cancelled: 'キャンセル済み',
},
toast: {
cancellingUpload: 'アップロードをキャンセル中...',
cancelled: '配信をキャンセルしました',
cancelFailed: '配信のキャンセルに失敗しました',
completeWithFailures: 'バックグラウンド配信完了: {{completed}} 件成功、{{failed}} 件失敗',
completeSuccess: 'バックグラウンド配信完了: {{completed}} 件成功',
},
},
stats: {
title: '統計',
overview: '概要',