Merge branch '0.2.1b2' into feature/cost_tracker

This commit is contained in:
Keybored
2026-02-20 15:44:34 +01:00
committed by GitHub
38 changed files with 11608 additions and 9150 deletions
+14
View File
@@ -42,3 +42,17 @@ repos:
pass_filenames: false
types: [python]
files: ^backend/app/
- id: frontend-typecheck
name: TypeScript type check
entry: bash -c 'cd frontend && npx tsc --noEmit'
language: system
pass_filenames: false
files: ^frontend/src/
types_or: [ts, tsx]
- id: frontend-lint
name: ESLint
entry: bash -c 'cd frontend && npx eslint .'
language: system
pass_filenames: false
files: ^frontend/src/
types_or: [ts, tsx]
+13 -1
View File
@@ -5,11 +5,23 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.1b2] - Unreleased
### Fixed
- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query.
- **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, the `on_ams_change` handler eagerly deleted the empty spool's `SpoolAssignment` record (fingerprint mismatch). When `on_print_complete` later ran, it queried `SpoolAssignment` live from the database, found nothing, and silently dropped usage. Now snapshots all spool assignments at print start into the `PrintSession`, so usage is correctly attributed at completion regardless of mid-print AMS changes.
- **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with `AttributeError: 'NoneType' object has no attribute 'set'`. The MQTT callback thread checked `self._pending_kprofile_response` (not None) at line 2698, but between that check and the `.set()` call, the asyncio thread's `finally` block in `get_kprofiles()` could clear the attribute to `None` after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.
- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.
- **Queue "Any Model" Jobs Stuck in "Waiting" After Plate Clear** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — After the queue visibility fix above, "Any Model" jobs were correctly assigned to an idle printer but immediately crashed with `'>=' not supported between instances of 'str' and 'int'` when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but `_build_loaded_filaments()` compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Cast `ams_id` and `tray_id` to `int()` to match the pattern already used for external spool IDs.
- **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.
- **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.
- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
### New Features
- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
- **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
### Improved
- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.
## [0.2.1b] - 2026-02-19
### Fixed
+1
View File
@@ -98,6 +98,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- CSV/Excel export
### ⏰ Scheduling & Automation
- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
- Print queue with drag-and-drop
- Multi-printer selection (send to multiple printers at once)
- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
+1
View File
@@ -40,6 +40,7 @@ Please include the following information in your report:
| Version | Supported |
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
| 0.2.x | :white_check_mark: |
## Security Considerations
+31 -120
View File
@@ -2431,7 +2431,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
@@ -2834,14 +2835,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
@@ -2871,139 +2867,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"],
}
+23 -12
View File
@@ -270,19 +270,30 @@ async def list_queue(
if printer_id == -1:
# Special value: filter for unassigned items
query = query.where(PrintQueueItem.printer_id.is_(None))
elif target_model:
# Include both printer-specific items AND model-based (unassigned) items
query = query.where(
or_(
PrintQueueItem.printer_id == printer_id,
and_(
PrintQueueItem.printer_id.is_(None),
func.lower(PrintQueueItem.target_model) == target_model.lower(),
),
)
)
else:
query = query.where(PrintQueueItem.printer_id == printer_id)
# Resolve effective model: prefer explicit param, fall back to printer's DB model.
# This ensures model-based "Any X" items are returned even when the frontend
# doesn't send target_model (e.g. printer.model is NULL on the client side).
effective_model = target_model
if not effective_model:
printer_row = (
await db.execute(select(Printer.model).where(Printer.id == printer_id))
).scalar_one_or_none()
effective_model = printer_row
if effective_model:
# Include both printer-specific items AND model-based (unassigned) items
query = query.where(
or_(
PrintQueueItem.printer_id == printer_id,
and_(
PrintQueueItem.printer_id.is_(None),
func.lower(PrintQueueItem.target_model) == effective_model.lower(),
),
)
)
else:
query = query.where(PrintQueueItem.printer_id == printer_id)
elif target_model:
query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())
if status:
+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
+124 -106
View File
@@ -5,6 +5,79 @@ 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,
inventory,
kprofiles,
library,
local_presets,
maintenance,
metrics,
notification_templates,
notifications,
pending_uploads,
print_log,
print_queue,
printers,
projects,
settings as settings_routes,
smart_plugs,
spoolman,
support,
system,
updates,
users,
virtual_printers,
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.mqtt_smart_plug import mqtt_smart_plug_service
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 +199,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,74 +240,7 @@ 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,
api_keys,
archives,
auth,
camera,
cloud,
discovery,
external_links,
filaments,
firmware,
github_backup,
groups,
inventory,
kprofiles,
library,
local_presets,
maintenance,
metrics,
notification_templates,
notifications,
pending_uploads,
print_log,
print_queue,
printers,
projects,
settings as settings_routes,
smart_plugs,
spoolman,
support,
system,
updates,
users,
virtual_printers,
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.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.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.mqtt_smart_plug import mqtt_smart_plug_service
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
# Track active prints: {(printer_id, filename): archive_id}
_active_prints: dict[tuple[int, str], int] = {}
@@ -320,7 +324,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):
@@ -1050,10 +1055,10 @@ async def on_print_start(printer_id: int, data: dict):
from backend.app.api.routes.settings import get_setting
_spoolman_on = await get_setting(db, "spoolman_enabled")
if not _spoolman_on or _spoolman_on.lower() != "true":
from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
if not _spoolman_on or _spoolman_on.lower() != "true":
from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
await usage_on_print_start(printer_id, data, printer_manager)
await usage_on_print_start(printer_id, data, printer_manager, db=db)
except Exception as e:
logger.warning("Usage tracker on_print_start failed: %s", e)
@@ -2114,40 +2119,46 @@ async def on_print_complete(printer_id: int, data: dict):
# auto-start files found in root on power cycle, causing ghost prints.
# Must run before the archive_id early-return so it executes even when archiving is disabled.
try:
printer_info = printer_manager.get_printer(printer_id)
if printer_info and subtask_name:
from backend.app.services.bambu_ftp import delete_file_async
if subtask_name:
async with async_session() as db:
from backend.app.models.printer import Printer
# Try both .3mf and .gcode extensions — the printer may have either
for ext in (".3mf", ".gcode"):
remote_path = f"/{subtask_name}{ext}"
# Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
for attempt in range(1, 4):
try:
delete_result = await delete_file_async(
printer_info.ip_address,
printer_info.access_code,
remote_path,
printer_model=printer_info.model,
)
if delete_result:
logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
break # Success or file doesn't exist — no need to retry
except Exception as e:
if attempt < 3:
logger.debug(
"SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
attempt,
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if printer:
from backend.app.services.bambu_ftp import delete_file_async
# Try both .3mf and .gcode extensions — the printer may have either
for ext in (".3mf", ".gcode"):
remote_path = f"/{subtask_name}{ext}"
# Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
for attempt in range(1, 4):
try:
delete_result = await delete_file_async(
printer.ip_address,
printer.access_code,
remote_path,
e,
)
await asyncio.sleep(2)
else:
logger.debug(
"SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
remote_path,
e,
printer_model=printer.model,
)
if delete_result:
logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
break # Success or file doesn't exist — no need to retry
except Exception as e:
if attempt < 3:
logger.debug(
"SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
attempt,
remote_path,
e,
)
await asyncio.sleep(2)
else:
logger.debug(
"SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
remote_path,
e,
)
except Exception as e:
logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
@@ -2869,7 +2880,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
@@ -3253,6 +3265,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()
@@ -3285,6 +3300,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()
@@ -3318,7 +3334,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",
@@ -3470,6 +3487,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
+856
View File
@@ -0,0 +1,856 @@
"""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.
"""
async with self._lock:
# Check active jobs first
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
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...",
}
)
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,
}
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
return result
# Check queued jobs
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:
if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
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)
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,
ams_mapping=job.options.get("ams_mapping"),
)
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)
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,
ams_mapping=job.options.get("ams_mapping"),
)
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()
+56 -9
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
# Per-chunk data socket timeout during upload.
UPLOAD_CHUNK_TIMEOUT = 120
# Cache for working FTP modes per printer IP
# Maps IP -> "prot_p" or "prot_c"
@@ -359,6 +362,7 @@ class BambuFTPClient:
logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
uploaded = 0
callback_exception: Exception | None = None
# Use manual transfer instead of storbinary() for A1 compatibility
# A1 printers have issues with storbinary's voidresp() hanging after transfer
@@ -368,7 +372,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 +386,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()
# Skip voidresp() for A1 models — they hang after transfercmd uploads
if self.printer_model not in self.A1_MODELS:
try:
self._ftp.voidresp()
except (OSError, ftplib.Error) as e:
# Data transfer already completed — voidresp() failure is just a noisy
# 226 acknowledgment issue, not an actual upload failure. Log and continue.
logger.warning("FTP upload response for %s was not clean (data already sent): %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
logger.info("FTP upload complete: %s", remote_path)
return True
@@ -421,7 +462,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 +473,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 +870,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 +881,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 +901,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)
+6 -3
View File
@@ -2695,13 +2695,16 @@ class BambuMQTTClient:
# Signal that we received the response (only if we were waiting for one)
# Use thread-safe method since MQTT callbacks run in a different thread
if self._pending_kprofile_response:
# Capture in local var to avoid TOCTOU race: asyncio thread can clear
# self._pending_kprofile_response between the check and the .set() call
event = self._pending_kprofile_response
if event:
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
self._loop.call_soon_threadsafe(event.set)
else:
# Fallback for when loop is not available
self._pending_kprofile_response.set()
event.set()
async def get_kprofiles(
self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
+2 -2
View File
@@ -512,14 +512,14 @@ class PrintScheduler:
# Parse AMS units from raw_data
ams_data = status.raw_data.get("ams", [])
for ams_unit in ams_data:
ams_id = ams_unit.get("id", 0)
ams_id = int(ams_unit.get("id", 0))
trays = ams_unit.get("tray", [])
is_ht = len(trays) == 1 # AMS-HT has single tray
for tray in trays:
tray_type = tray.get("tray_type")
if tray_type:
tray_id = tray.get("id", 0)
tray_id = int(tray.get("id", 0))
tray_color = tray.get("tray_color", "")
# tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
tray_info_idx = tray.get("tray_info_idx", "")
+53 -25
View File
@@ -155,14 +155,17 @@ class PrintSession:
tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
# tray_now at print start (correct value, unlike at completion where it's 255)
tray_now_at_start: int = -1
# Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
# Prevents usage loss when on_ams_change unlinks a spool mid-print
spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
# Module-level storage, keyed by printer_id
_active_sessions: dict[int, PrintSession] = {}
async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
"""Capture AMS tray remain% at print start."""
async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
"""Capture AMS tray remain% and spool assignments at print start."""
state = printer_manager.get_status(printer_id)
if not state or not state.raw_data:
logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
@@ -215,6 +218,20 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
)
logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
# Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
spool_assignments: dict[tuple[int, int], int] = {}
if db:
assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
for assignment in assign_result.scalars().all():
spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
if spool_assignments:
logger.info(
"[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
len(spool_assignments),
printer_id,
{f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
)
# Always create session (even without valid remain data) so print_name
# is available at completion for 3MF-based tracking
session = PrintSession(
@@ -223,6 +240,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
started_at=datetime.now(timezone.utc),
tray_remain_start=tray_remain_start,
tray_now_at_start=tray_now_at_start,
spool_assignments=spool_assignments,
)
_active_sessions[printer_id] = session
@@ -304,6 +322,7 @@ async def on_print_complete(
last_progress=data.get("last_progress", 0.0),
last_layer_num=data.get("last_layer_num", 0),
default_filament_cost=default_filament_cost,
spool_assignments=session.spool_assignments if session else None,
)
results.extend(threemf_results)
@@ -338,20 +357,23 @@ async def on_print_complete(
if delta_pct <= 0:
continue # No consumption or tray was refilled
# Look up SpoolAssignment for this slot
result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
# Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
if spool_id is None:
result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
)
assignment = result.scalar_one_or_none()
if not assignment:
continue
assignment = result.scalar_one_or_none()
if not assignment:
continue
spool_id = assignment.spool_id
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
@@ -454,6 +476,7 @@ async def _track_from_3mf(
last_progress: float = 0.0,
last_layer_num: int = 0,
default_filament_cost: float = 0.0,
spool_assignments: dict[tuple[int, int], int] | None = None,
) -> list[dict]:
"""Track usage from 3MF per-filament slicer data (primary path).
@@ -662,21 +685,26 @@ async def _track_from_3mf(
if key in handled_trays:
continue
# Find spool assignment for this tray
assign_result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
# Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
spool_id = spool_assignments.get(key) if spool_assignments else None
if spool_id is None:
assign_result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
)
assignment = assign_result.scalar_one_or_none()
if not assignment:
logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
continue
assignment = assign_result.scalar_one_or_none()
if not assignment:
logger.info(
"[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
)
continue
spool_id = assignment.spool_id
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
@@ -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,322 @@
"""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
@pytest.mark.asyncio
async def test_cancel_job_not_found_returns_false():
"""Cancelling a nonexistent job returns not_found."""
service = BackgroundDispatchService()
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
result = await service.cancel_job(999)
assert result["cancelled"] is False
assert result["reason"] == "not_found"
@pytest.mark.asyncio
async def test_cancel_job_single_lock_covers_both_active_and_queued():
"""cancel_job checks both active and queued jobs under a single lock acquisition.
Regression test for TOCTOU race: previously two separate lock acquisitions allowed
the dispatcher loop to move a job from queue to active between them, causing cancel
to find it in neither place.
"""
service = BackgroundDispatchService()
# Set up a job in the queue AND an active job for a different printer
active_job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="active.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
queued_job = PrintDispatchJob(
id=2,
kind="reprint_archive",
source_id=20,
source_name="queued.3mf",
printer_id=2,
printer_name="Printer 2",
)
service._queued_jobs.append(queued_job)
service._batch_total = 2
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast:
# Cancel the queued job — should find it in single lock acquisition
result = await service.cancel_job(2)
assert result["cancelled"] is True
assert result["pending"] is False
assert len(service._queued_jobs) == 0
# Active job should be untouched
assert 1 in service._active_jobs
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_mark_job_finished_resets_batch_when_all_done():
"""Batch counters reset after last job completes."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._batch_total = 1
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
await service._mark_job_finished(job, failed=False, message="Complete")
assert service._batch_total == 0
assert service._batch_completed == 0
assert service._batch_failed == 0
@pytest.mark.asyncio
async def test_mark_job_finished_no_reset_when_jobs_remain():
"""Batch counters NOT reset when queued jobs remain."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
remaining_job = PrintDispatchJob(
id=2,
kind="reprint_archive",
source_id=20,
source_name="next.3mf",
printer_id=2,
printer_name="Printer 2",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._queued_jobs.append(remaining_job)
service._batch_total = 2
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
await service._mark_job_finished(job, failed=False, message="Complete")
# Batch counters should NOT be reset — remaining job still queued
assert service._batch_total == 2
assert service._batch_completed == 1
@pytest.mark.asyncio
async def test_mark_job_finished_batch_reset_rechecks_under_lock():
"""Batch reset re-checks condition inside second lock acquisition.
Regression test for TOCTOU: a new dispatch between the two lock acquisitions
could get its counters zeroed if the re-check is missing.
"""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._batch_total = 1
original_broadcast = AsyncMock()
async def inject_new_job_during_broadcast(msg):
"""Simulate a new dispatch arriving between the two lock acquisitions."""
await original_broadcast(msg)
# After broadcast (lock released), inject a new job before reset re-check
if not service._queued_jobs:
new_job = PrintDispatchJob(
id=99,
kind="reprint_archive",
source_id=99,
source_name="injected.3mf",
printer_id=5,
printer_name="Printer 5",
)
service._queued_jobs.append(new_job)
service._batch_total = 1
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast",
side_effect=inject_new_job_during_broadcast,
):
await service._mark_job_finished(job, failed=False, message="Complete")
# Re-check should prevent reset since a new job appeared
assert service._batch_total == 1
assert len(service._queued_jobs) == 1
@@ -870,3 +870,47 @@ class TestFailureScenarios:
result2 = client.download_file("/cache/retry.bin")
assert result2 == b"data after retry"
client.disconnect()
def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
"""Upload returns True even when voidresp() gets a non-clean response.
Regression: Previously, a voidresp() error after successful data transfer
returned False, which caused with_ftp_retry to re-upload the entire file
in a loop.
"""
content = b"voidresp test data"
local = tmp_path / "voidresp_test.3mf"
local.write_bytes(content)
client = ftp_client_factory(printer_model="X1C")
client.connect()
result = client.upload_file(local, "/cache/voidresp_test.3mf")
assert result is True
client.disconnect()
# Verify the file is actually on the server
time.sleep(_UPLOAD_FLUSH_DELAY)
client2 = ftp_client_factory()
client2.connect()
downloaded = client2.download_file("/cache/voidresp_test.3mf")
assert downloaded == content
client2.disconnect()
def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
"""A1 models skip voidresp() entirely and still return True.
Regression: A1 printers hang on voidresp() after transfercmd uploads.
"""
content = b"A1 upload test"
local = tmp_path / "a1_test.3mf"
local.write_bytes(content)
client = ftp_client_factory(printer_model="A1")
client.connect()
result = client.upload_file(local, "/cache/a1_test.3mf")
assert result is True
client.disconnect()
# Verify the file is actually on the server
time.sleep(_UPLOAD_FLUSH_DELAY)
client2 = ftp_client_factory()
client2.connect()
downloaded = client2.download_file("/cache/a1_test.3mf")
assert downloaded == content
client2.disconnect()
@@ -399,3 +399,274 @@ class TestTrackFrom3MF:
assert len(results) == 1
assert results[0]["ams_id"] == 1
assert results[0]["tray_id"] == 0
class TestSpoolAssignmentSnapshot:
"""Tests for spool assignment snapshotting at print start (#459).
When a spool runs empty mid-print, on_ams_change deletes the SpoolAssignment.
The snapshot captured at print start ensures usage is still attributed correctly.
"""
@pytest.fixture(autouse=True)
def _clear_sessions(self):
_active_sessions.clear()
yield
_active_sessions.clear()
@pytest.mark.asyncio
async def test_on_print_start_snapshots_assignments_with_db(self):
"""on_print_start captures spool assignments when db is provided."""
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 60}]}]
pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
assignment_0 = _make_assignment(spool_id=10, printer_id=1, ams_id=0, tray_id=0)
assignment_1 = _make_assignment(spool_id=20, printer_id=1, ams_id=0, tray_id=1)
db = AsyncMock()
scalars_mock = MagicMock()
scalars_mock.all.return_value = [assignment_0, assignment_1]
result_mock = MagicMock()
result_mock.scalars.return_value = scalars_mock
db.execute = AsyncMock(return_value=result_mock)
await on_print_start(1, {"subtask_name": "Benchy"}, pm, db=db)
session = _active_sessions[1]
assert session.spool_assignments == {(0, 0): 10, (0, 1): 20}
@pytest.mark.asyncio
async def test_on_print_start_empty_snapshot_without_db(self):
"""on_print_start creates empty snapshot when no db provided."""
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
await on_print_start(1, {"subtask_name": "Benchy"}, pm)
session = _active_sessions[1]
assert session.spool_assignments == {}
@pytest.mark.asyncio
async def test_3mf_uses_snapshot_instead_of_live_query(self):
"""_track_from_3mf uses snapshot spool_id without querying SpoolAssignment."""
spool = _make_spool(id=42, label_weight=1000)
archive = MagicMock()
archive.file_path = "archives/test.3mf"
# db: archive, queue_item(None), spool — NO assignment query needed
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager(_make_printer_state([], tray_now=0))
filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="Test",
handled_trays=set(),
printer_manager=pm,
db=db,
spool_assignments={(0, 0): 42},
)
assert len(results) == 1
assert results[0]["spool_id"] == 42
assert results[0]["weight_used"] == 15.0
@pytest.mark.asyncio
async def test_3mf_falls_back_to_live_query_without_snapshot(self):
"""_track_from_3mf queries SpoolAssignment when no snapshot exists."""
spool = _make_spool(id=5, label_weight=1000)
assignment = _make_assignment(spool_id=5)
archive = MagicMock()
archive.file_path = "archives/test.3mf"
# db: archive, queue_item(None), assignment, spool
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager(_make_printer_state([], tray_now=0))
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="Test",
handled_trays=set(),
printer_manager=pm,
db=db,
spool_assignments=None,
)
assert len(results) == 1
assert results[0]["spool_id"] == 5
@pytest.mark.asyncio
async def test_ams_delta_uses_snapshot_over_live_query(self):
"""AMS remain% fallback uses snapshot spool_id instead of live query."""
spool = _make_spool(id=77, label_weight=1000)
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="Benchy",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
spool_assignments={(0, 0): 77},
)
# Current remain = 70% → 10% delta → 100g
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
# db only returns spool (NO assignment query)
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
results = await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=pm,
db=db,
archive_id=None,
)
assert len(results) == 1
assert results[0]["spool_id"] == 77
assert results[0]["weight_used"] == 100.0
@pytest.mark.asyncio
async def test_ams_delta_falls_back_to_live_query_without_snapshot(self):
"""AMS remain% fallback queries SpoolAssignment when snapshot is empty."""
spool = _make_spool(id=33, label_weight=1000)
assignment = _make_assignment(spool_id=33)
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="Benchy",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
spool_assignments={}, # Empty snapshot (pre-upgrade session)
)
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
# db returns assignment then spool
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
results = await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=pm,
db=db,
archive_id=None,
)
assert len(results) == 1
assert results[0]["spool_id"] == 33
@pytest.mark.asyncio
async def test_snapshot_survives_mid_print_unlink(self):
"""Core bug scenario: snapshot provides spool_id after mid-print unlink.
Simulates the #459 scenario: spool runs empty mid-print, on_ams_change
deletes the SpoolAssignment, but the snapshot from print start still
has the spool_id so usage is correctly attributed at print completion.
"""
spool = _make_spool(id=8, label_weight=1000, weight_used=50)
archive = MagicMock()
archive.file_path = "archives/big_print.3mf"
# Session was created at print start WITH snapshot
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="Big Print",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 90},
spool_assignments={(0, 0): 8}, # Snapshot from print start
)
pm = _make_printer_manager(
_make_printer_state(
[{"id": 0, "tray": [{"id": 0, "remain": 75}]}],
tray_now=0,
)
)
filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
# db: archive, queue_item(None), spool
# NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=pm,
db=db,
archive_id=100,
)
# Usage should be tracked despite assignment being deleted mid-print
assert len(results) >= 1
assert results[0]["spool_id"] == 8
assert results[0]["weight_used"] == 14.2
# Spool weight should be updated: 50 + 14.2 = 64.2
assert spool.weight_used == 64.2
@@ -0,0 +1,88 @@
/**
* Test that reprint mode does not show the "Print queued for printer" toast.
* The background dispatch websocket toast handles feedback instead.
*
* Separate file because vi.mock(ToastContext) must be module-scoped
* and would interfere with the main PrintModal test suite.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
// Mock the toast context before importing the component
const mockShowToast = vi.fn();
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
return {
...actual,
useToast: () => ({ showToast: mockShowToast }),
};
});
import { render } from '../utils';
import { PrintModal } from '../../components/PrintModal';
const mockPrinters = [
{ id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
];
describe('PrintModal dispatch toast', () => {
const mockOnClose = vi.fn();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json({ is_multi_plate: false, plates: [] });
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json({ filaments: [] });
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
}),
http.post('/api/v1/archives/:id/reprint', () => {
return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 1 });
}),
);
});
it('does not show "queued" toast in reprint mode (dispatch toast handles it)', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// Wait for printers to load, then select one
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
await user.click(screen.getByText('X1 Carbon'));
// Submit the print
const printButton = screen.getByRole('button', { name: /^print$/i });
await user.click(printButton);
// Wait for the API call to complete and modal to close
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
// showToast should NOT have been called with "Print queued for printer"
const toastMessages = mockShowToast.mock.calls.map(call => call[0]);
expect(toastMessages).not.toContain('Print queued for printer');
});
});
+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;
@@ -2939,6 +2948,7 @@ export const api = {
printerId: number,
options?: {
plate_id?: number;
plate_name?: string;
ams_mapping?: number[];
timelapse?: boolean;
bed_levelling?: boolean;
@@ -2948,7 +2958,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',
@@ -4010,6 +4020,7 @@ export const api = {
printerId: number,
options?: {
plate_id?: number;
plate_name?: string;
ams_mapping?: number[];
bed_levelling?: boolean;
flow_cali?: boolean;
@@ -4019,13 +4030,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) =>
+30 -18
View File
@@ -1,28 +1,28 @@
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 { getCurrencySymbol } from '../../utils/currency';
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';
@@ -234,6 +234,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({
@@ -454,12 +460,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,
});
@@ -497,16 +506,19 @@ export function PrintModal({
setIsSubmitting(false);
// Show result toast
// Show result toast (skip for reprint mode — the dispatch toast handles it)
if (results.failed === 0) {
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 !== 'reprint') {
if (assignmentMode === 'model') {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
} else {
showToast(`Print ${action} ${results.success} printers`);
if (mode === 'edit-queue-item') {
showToast('Queue item updated');
} else if (results.success === 1) {
showToast('Print queued for printer');
} else {
showToast(`Print queued for ${results.success} printers`);
}
}
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
@@ -749,4 +761,4 @@ 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((toastItem) => toastItem.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((toastItem) => toastItem.id === dispatchToastId);
if (exists) {
return prev.map((toastItem) =>
toastItem.id === dispatchToastId
? {
...toastItem,
message: t('backgroundDispatch.startingPrints'),
type: 'loading',
persistent: true,
dispatchData,
}
: toastItem
);
}
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>
+9 -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;
@@ -250,6 +250,14 @@ export function useWebSocket() {
}
}));
break;
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
@@ -882,6 +882,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: 'Tableau de bord',
+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: '概要',
+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',
+28 -15
View File
@@ -16,6 +16,8 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
import { useToast } from '../contexts/ToastContext';
import { resolveSpoolColorName } from '../utils/colors';
import { getCurrencySymbol } from '../utils/currency';
import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
import { formatSlotLabel } from '../utils/amsHelpers';
type ArchiveFilter = 'active' | 'archived';
type UsageFilter = 'all' | 'used' | 'new';
@@ -100,10 +102,11 @@ const MATERIAL_COLORS: Record<string, string> = {
type TFn = (key: string) => string;
function formatDate(dateStr: string | null): string {
function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' });
const date = parseUTCDate(dateStr);
if (!date) return '-';
return formatDateInput(date, dateFormat);
}
type CellCtx = {
@@ -112,6 +115,7 @@ type CellCtx = {
pct: number;
assignmentMap: Record<number, SpoolAssignment>;
currencySymbol: string;
dateFormat: DateFormat;
};
// Column header labels (25 columns — matching SpoolBuddy exactly)
@@ -148,14 +152,14 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
id: ({ spool }) => (
<span className="text-sm font-medium text-white">{spool.id}</span>
),
added_time: ({ spool }) => (
<span className="text-sm text-bambu-gray">{formatDate(spool.created_at)}</span>
added_time: ({ spool, dateFormat }) => (
<span className="text-sm text-bambu-gray">{formatInventoryDate(spool.created_at, dateFormat)}</span>
),
encode_time: ({ spool }) => (
<span className="text-sm text-bambu-gray">{formatDate(spool.encode_time)}</span>
encode_time: ({ spool, dateFormat }) => (
<span className="text-sm text-bambu-gray">{formatInventoryDate(spool.encode_time, dateFormat)}</span>
),
last_used_time: ({ spool }) => (
<span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
last_used_time: ({ spool, dateFormat }) => (
<span className="text-sm text-bambu-gray">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>
),
rgba: ({ spool }) => (
<div className="flex items-center justify-center">
@@ -187,12 +191,12 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
const assignment = assignmentMap[spool.id];
if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
// Bambu slot notation: AMS 0=A, 1=B, 2=C, 3=D; tray 0-based → 1-based
const slotLetter = String.fromCharCode(65 + assignment.ams_id);
const slotNumber = assignment.tray_id + 1;
const isExternal = assignment.ams_id === 254 || assignment.ams_id === 255;
const isHt = !isExternal && assignment.ams_id >= 128;
const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
{printerLabel} {slotLetter}{slotNumber}
{printerLabel} {slotLabel}
</span>
);
},
@@ -276,7 +280,9 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
location: (s, am) => {
const a = am[s.id];
if (!a) return '';
return `${a.printer_name || ''} ${String.fromCharCode(65 + a.ams_id)}${a.tray_id + 1}`;
const isExt = a.ams_id === 254 || a.ams_id === 255;
const isHt = !isExt && a.ams_id >= 128;
return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}`;
},
label_weight: (s) => s.label_weight,
net: (s) => Math.max(0, s.label_weight - s.weight_used),
@@ -340,6 +346,13 @@ export default function InventoryPage() {
return 15;
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const dateFormat: DateFormat = settings?.date_format || 'system';
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
@@ -944,7 +957,7 @@ export default function InventoryPage() {
>
{visibleColumns.map((colId) => (
<td key={colId} className="py-3 px-4">
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol })}
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat })}
</td>
))}
<td className="py-3 px-4">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-nSSxwAwH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-pLsuHUG_.css">
<script type="module" crossorigin src="/assets/index-uDg4paLx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJax8qcY.css">
</head>
<body>
<div id="root"></div>