mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Merge branch '0.2.1b2' into feature/cost_tracker
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '概要',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user