|
@@ -3,6 +3,7 @@ import logging
|
|
|
from contextlib import asynccontextmanager
|
|
from contextlib import asynccontextmanager
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from datetime import datetime, timedelta, timezone
|
|
|
from logging.handlers import RotatingFileHandler
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# =============================================================================
|
|
@@ -294,85 +295,475 @@ _last_status_broadcast: dict[int, str] = {}
|
|
|
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
|
|
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _report_spoolman_usage(printer_id: int, archive_id: int, logger):
|
|
|
|
|
- """Report filament usage to Spoolman after print completion.
|
|
|
|
|
|
|
+def _build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
|
|
|
|
|
+ """Build lookup of global_tray_id -> tray info from printer state.
|
|
|
|
|
+
|
|
|
|
|
+ Returns: {0: {"tray_uuid": "...", "tag_uid": "..."}, 1: {...}, ...}
|
|
|
|
|
+ """
|
|
|
|
|
+ lookup = {}
|
|
|
|
|
+ ams_data = raw_data.get("ams", [])
|
|
|
|
|
+ for ams_unit in ams_data:
|
|
|
|
|
+ ams_id = ams_unit.get("id", 0)
|
|
|
|
|
+ for tray in ams_unit.get("tray", []):
|
|
|
|
|
+ tray_id = tray.get("id", 0)
|
|
|
|
|
+ global_tray_id = ams_id * 4 + tray_id
|
|
|
|
|
+ lookup[global_tray_id] = {
|
|
|
|
|
+ "tray_uuid": tray.get("tray_uuid", ""),
|
|
|
|
|
+ "tag_uid": tray.get("tag_uid", ""),
|
|
|
|
|
+ "tray_type": tray.get("tray_type", ""),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ # External spool (global_tray_id = 254)
|
|
|
|
|
+ vt_tray = raw_data.get("vt_tray")
|
|
|
|
|
+ if vt_tray and vt_tray.get("tray_type"):
|
|
|
|
|
+ lookup[254] = {
|
|
|
|
|
+ "tray_uuid": vt_tray.get("tray_uuid", ""),
|
|
|
|
|
+ "tag_uid": vt_tray.get("tag_uid", ""),
|
|
|
|
|
+ "tray_type": vt_tray.get("tray_type", ""),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return lookup
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _store_spoolman_print_data(printer_id: int, archive_id: int, file_path: str, db, logger):
|
|
|
|
|
+ """Store Spoolman tracking data at print start (persisted to database)."""
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+ from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
|
|
|
|
+ from backend.app.utils.threemf_tools import (
|
|
|
|
|
+ extract_filament_properties_from_3mf,
|
|
|
|
|
+ extract_filament_usage_from_3mf,
|
|
|
|
|
+ extract_layer_filament_usage_from_3mf,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Check if Spoolman is enabled
|
|
|
|
|
+ spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
|
|
|
|
+ if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Only store tracking data if "Disable AMS Weight Sync" is enabled
|
|
|
|
|
+ # (otherwise we're using AMS percentage-based estimates, not per-usage tracking)
|
|
|
|
|
+ disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
|
|
|
|
|
+ disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
|
|
|
|
|
+ if not disable_weight_sync:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] Weight sync enabled, skipping per-usage tracking data storage")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Get 3MF file path
|
|
|
|
|
+ full_path = app_settings.base_dir / file_path
|
|
|
|
|
+ if not full_path.exists():
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] 3MF file not found: {full_path}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Extract per-filament usage from 3MF (total usage per slot)
|
|
|
|
|
+ filament_usage = extract_filament_usage_from_3mf(full_path)
|
|
|
|
|
+ if not filament_usage:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] No filament usage data in 3MF for archive {archive_id}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Get current AMS tray state
|
|
|
|
|
+ state = printer_manager.get_status(printer_id)
|
|
|
|
|
+ ams_trays = {}
|
|
|
|
|
+ if state and state.raw_data:
|
|
|
|
|
+ ams_trays = _build_ams_tray_lookup(state.raw_data)
|
|
|
|
|
+
|
|
|
|
|
+ # Get custom slot-to-tray mapping from queue item (if this is a queued print)
|
|
|
|
|
+ slot_to_tray = None
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ queue_result = await db.execute(
|
|
|
|
|
+ select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
|
|
|
|
|
+ )
|
|
|
|
|
+ queue_item = queue_result.scalar_one_or_none()
|
|
|
|
|
+ if queue_item and queue_item.ams_mapping:
|
|
|
|
|
+ try:
|
|
|
|
|
+ slot_to_tray = json.loads(queue_item.ams_mapping)
|
|
|
|
|
+ except json.JSONDecodeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
|
|
|
|
|
+ layer_usage = extract_layer_filament_usage_from_3mf(full_path)
|
|
|
|
|
+ layer_usage_json = None
|
|
|
|
|
+ if layer_usage:
|
|
|
|
|
+ # Convert int keys to string for JSON serialization
|
|
|
|
|
+ layer_usage_json = {str(k): v for k, v in layer_usage.items()}
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Parsed {len(layer_usage)} layers from G-code")
|
|
|
|
|
+
|
|
|
|
|
+ # Extract filament properties (density, diameter) for mm -> grams conversion
|
|
|
|
|
+ filament_properties = extract_filament_properties_from_3mf(full_path)
|
|
|
|
|
+
|
|
|
|
|
+ # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
|
|
|
|
|
+ from sqlalchemy import delete
|
|
|
|
|
+
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ delete(ActivePrintSpoolman)
|
|
|
|
|
+ .where(ActivePrintSpoolman.printer_id == printer_id)
|
|
|
|
|
+ .where(ActivePrintSpoolman.archive_id == archive_id)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Insert new tracking data
|
|
|
|
|
+ tracking = ActivePrintSpoolman(
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ archive_id=archive_id,
|
|
|
|
|
+ filament_usage=filament_usage,
|
|
|
|
|
+ ams_trays=ams_trays,
|
|
|
|
|
+ slot_to_tray=slot_to_tray,
|
|
|
|
|
+ layer_usage=layer_usage_json,
|
|
|
|
|
+ filament_properties=filament_properties,
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(tracking)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Stored tracking data for print: printer={printer_id}, archive={archive_id}")
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Filament usage: {filament_usage}")
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] AMS trays: {list(ams_trays.keys())}")
|
|
|
|
|
+ if slot_to_tray:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Custom slot mapping: {slot_to_tray}")
|
|
|
|
|
+ if layer_usage_json:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Layer usage data available for partial tracking")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _cleanup_spoolman_tracking(printer_id: int, archive_id: int, db, logger):
|
|
|
|
|
+ """Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
|
|
|
|
|
+ from sqlalchemy import delete, select
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
|
|
|
|
+
|
|
|
|
|
+ # Get tracking data first (needed for partial usage reporting)
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(ActivePrintSpoolman)
|
|
|
|
|
+ .where(ActivePrintSpoolman.printer_id == printer_id)
|
|
|
|
|
+ .where(ActivePrintSpoolman.archive_id == archive_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ tracking = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if not tracking:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] No tracking data to clean up for printer={printer_id}, archive={archive_id}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Try to report partial usage before cleanup
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _report_partial_spoolman_usage(printer_id, tracking, logger)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning(f"[SPOOLMAN] Partial usage report failed: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ # Delete tracking data
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ delete(ActivePrintSpoolman)
|
|
|
|
|
+ .where(ActivePrintSpoolman.printer_id == printer_id)
|
|
|
|
|
+ .where(ActivePrintSpoolman.archive_id == archive_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Cleaned up tracking data for printer={printer_id}, archive={archive_id}")
|
|
|
|
|
+
|
|
|
|
|
|
|
|
- This finds the spool by RFID tag_uid from current AMS state and reports
|
|
|
|
|
- the filament_used_grams from the archive metadata.
|
|
|
|
|
|
|
+async def _report_partial_spoolman_usage(printer_id: int, tracking, logger):
|
|
|
|
|
+ """Report partial filament usage based on actual G-code layer data.
|
|
|
|
|
+
|
|
|
|
|
+ Uses per-layer cumulative extrusion from G-code parsing for accurate
|
|
|
|
|
+ multi-material tracking. Falls back to linear interpolation if G-code
|
|
|
|
|
+ data is unavailable.
|
|
|
|
|
+
|
|
|
|
|
+ This is called when a print fails or is cancelled.
|
|
|
"""
|
|
"""
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+ from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams
|
|
|
|
|
+
|
|
|
async with async_session() as db:
|
|
async with async_session() as db:
|
|
|
- from backend.app.api.routes.settings import get_setting
|
|
|
|
|
- from backend.app.models.archive import PrintArchive
|
|
|
|
|
|
|
+ # Check if partial usage reporting is enabled (default: true)
|
|
|
|
|
+ report_partial = await get_setting(db, "spoolman_report_partial_usage")
|
|
|
|
|
+ if report_partial and report_partial.lower() == "false":
|
|
|
|
|
+ logger.debug("[SPOOLMAN] Partial usage reporting disabled by setting")
|
|
|
|
|
+ return
|
|
|
|
|
|
|
|
# Check if Spoolman is enabled
|
|
# Check if Spoolman is enabled
|
|
|
spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
|
spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
|
|
if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
|
if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- # Get Spoolman URL
|
|
|
|
|
- spoolman_url = await get_setting(db, "spoolman_url")
|
|
|
|
|
- if not spoolman_url:
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ # Get current printer state for layer progress
|
|
|
|
|
+ state = printer_manager.get_status(printer_id)
|
|
|
|
|
+ if not state:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] No printer state available for partial usage")
|
|
|
|
|
+ return
|
|
|
|
|
|
|
|
- # Get or create Spoolman client
|
|
|
|
|
|
|
+ current_layer = state.layer_num
|
|
|
|
|
+ total_layers = state.total_layers
|
|
|
|
|
+
|
|
|
|
|
+ # Need current layer to calculate usage
|
|
|
|
|
+ if not current_layer or current_layer <= 0:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] No progress to report (layer 0 or unknown)")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Reporting partial usage at layer {current_layer}/{total_layers or '?'}")
|
|
|
|
|
+
|
|
|
|
|
+ # Get tracking data
|
|
|
|
|
+ layer_usage = tracking.layer_usage
|
|
|
|
|
+ filament_properties = tracking.filament_properties or {}
|
|
|
|
|
+ filament_usage = tracking.filament_usage or []
|
|
|
|
|
+ ams_trays = tracking.ams_trays or {}
|
|
|
|
|
+ slot_to_tray = tracking.slot_to_tray
|
|
|
|
|
+
|
|
|
|
|
+ # Convert ams_trays keys from string to int (JSON serialization)
|
|
|
|
|
+ ams_trays = {int(k): v for k, v in ams_trays.items()}
|
|
|
|
|
+
|
|
|
|
|
+ async with async_session() as db:
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+
|
|
|
|
|
+ # Get Spoolman client
|
|
|
client = await get_spoolman_client()
|
|
client = await get_spoolman_client()
|
|
|
if not client:
|
|
if not client:
|
|
|
- client = await init_spoolman_client(spoolman_url)
|
|
|
|
|
|
|
+ spoolman_url = await get_setting(db, "spoolman_url")
|
|
|
|
|
+ if spoolman_url:
|
|
|
|
|
+ client = await init_spoolman_client(spoolman_url)
|
|
|
|
|
|
|
|
- # Check if Spoolman is reachable
|
|
|
|
|
- if not await client.health_check():
|
|
|
|
|
- logger.warning("Spoolman not reachable for usage reporting")
|
|
|
|
|
|
|
+ if not client or not await client.health_check():
|
|
|
|
|
+ logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- # Get archive to find filament usage
|
|
|
|
|
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
|
|
|
|
- archive = result.scalar_one_or_none()
|
|
|
|
|
- if not archive or not archive.filament_used_grams:
|
|
|
|
|
- logger.debug(f"No filament usage data for archive {archive_id}")
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ spools_updated = 0
|
|
|
|
|
|
|
|
- filament_used = archive.filament_used_grams
|
|
|
|
|
- logger.info(f"[SPOOLMAN] Archive {archive_id} used {filament_used}g of filament")
|
|
|
|
|
|
|
+ # Try to use accurate G-code parsed data
|
|
|
|
|
+ if layer_usage:
|
|
|
|
|
+ # Convert string keys back to int (JSON serialization issue)
|
|
|
|
|
+ # Both outer (layer) and inner (filament_id) keys need conversion
|
|
|
|
|
+ layer_usage_int = {
|
|
|
|
|
+ int(layer): {int(fid): mm for fid, mm in filaments.items()}
|
|
|
|
|
+ for layer, filaments in layer_usage.items()
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- # Get current AMS state from printer to find the active spool
|
|
|
|
|
- state = printer_manager.get_status(printer_id)
|
|
|
|
|
- if not state or not state.raw_data:
|
|
|
|
|
- logger.debug("No printer state available for usage reporting")
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ # Get cumulative usage at current layer
|
|
|
|
|
+ usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
|
|
|
|
|
|
|
|
- ams_data = state.raw_data.get("ams")
|
|
|
|
|
- if not ams_data:
|
|
|
|
|
- logger.debug("No AMS data available for usage reporting")
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ if usage_mm:
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Using G-code parsed data for layer {current_layer}")
|
|
|
|
|
|
|
|
- # Find spools with RFID tags in Spoolman and report usage
|
|
|
|
|
- # For now, we report usage to the first spool found with a matching tag
|
|
|
|
|
- # TODO: In future, track which specific trays were used during the print
|
|
|
|
|
- spools_updated = 0
|
|
|
|
|
- for ams_unit in ams_data:
|
|
|
|
|
- trays = ams_unit.get("tray", [])
|
|
|
|
|
|
|
+ # Process each filament's usage
|
|
|
|
|
+ for filament_id, mm_used in usage_mm.items():
|
|
|
|
|
+ # filament_id is 0-based from G-code, slot_id is 1-based
|
|
|
|
|
+ slot_id = filament_id + 1
|
|
|
|
|
|
|
|
- for tray_data in trays:
|
|
|
|
|
- tag_uid = tray_data.get("tag_uid")
|
|
|
|
|
- if not tag_uid:
|
|
|
|
|
- continue
|
|
|
|
|
|
|
+ # Determine which tray was used for this slot
|
|
|
|
|
+ global_tray_id = slot_id - 1 # Default: slot 1 -> tray 0
|
|
|
|
|
+ if slot_to_tray and slot_id <= len(slot_to_tray):
|
|
|
|
|
+ mapped_tray = slot_to_tray[slot_id - 1]
|
|
|
|
|
+ if mapped_tray >= 0:
|
|
|
|
|
+ global_tray_id = mapped_tray
|
|
|
|
|
+
|
|
|
|
|
+ tray_info = ams_trays.get(global_tray_id)
|
|
|
|
|
+ if not tray_info:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Slot {slot_id}: no tray at global_tray_id {global_tray_id}")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Get spool identifier (prefer tray_uuid over tag_uid)
|
|
|
|
|
+ tray_uuid = tray_info.get("tray_uuid", "")
|
|
|
|
|
+ tag_uid = tray_info.get("tag_uid", "")
|
|
|
|
|
+ spool_tag = (
|
|
|
|
|
+ tray_uuid
|
|
|
|
|
+ if tray_uuid and tray_uuid != "00000000000000000000000000000000"
|
|
|
|
|
+ else tag_uid
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if not spool_tag:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Slot {slot_id}: no identifier for tray {global_tray_id}")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Find the spool in Spoolman
|
|
|
|
|
+ spool = await client.find_spool_by_tag(spool_tag)
|
|
|
|
|
+ if not spool:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Slot {slot_id}: no spool for tag {spool_tag[:16]}...")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Get density from Spoolman's filament data (most accurate)
|
|
|
|
|
+ # Falls back to 3MF properties, then to default PLA density
|
|
|
|
|
+ filament_data = spool.get("filament", {})
|
|
|
|
|
+ density = filament_data.get("density")
|
|
|
|
|
+ diameter = filament_data.get("diameter", 1.75)
|
|
|
|
|
+
|
|
|
|
|
+ if not density:
|
|
|
|
|
+ # Fallback to 3MF properties
|
|
|
|
|
+ props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
|
|
|
|
|
+ density = props.get("density", 1.24)
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Using fallback density {density} for slot {slot_id}")
|
|
|
|
|
+
|
|
|
|
|
+ # Convert mm to grams using Spoolman's filament density
|
|
|
|
|
+ grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
|
|
|
|
|
+
|
|
|
|
|
+ if grams_used <= 0:
|
|
|
|
|
+ continue
|
|
|
|
|
|
|
|
- # Find spool in Spoolman by tag
|
|
|
|
|
- spool = await client.find_spool_by_tag(tag_uid)
|
|
|
|
|
- if spool:
|
|
|
|
|
# Report usage to Spoolman
|
|
# Report usage to Spoolman
|
|
|
- result = await client.use_spool(spool["id"], filament_used)
|
|
|
|
|
|
|
+ result = await client.use_spool(spool["id"], grams_used)
|
|
|
if result:
|
|
if result:
|
|
|
logger.info(
|
|
logger.info(
|
|
|
- f"[SPOOLMAN] Reported {filament_used}g usage to spool {spool['id']} (tag: {tag_uid})"
|
|
|
|
|
|
|
+ f"[SPOOLMAN] Partial (G-code): slot {slot_id}: {grams_used}g ({mm_used:.1f}mm, d={density}) -> spool {spool['id']}"
|
|
|
)
|
|
)
|
|
|
spools_updated += 1
|
|
spools_updated += 1
|
|
|
- # Only report to one spool for single-material prints
|
|
|
|
|
- # Multi-material prints would need more sophisticated tracking
|
|
|
|
|
- return
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if spools_updated > 0:
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Reported partial usage to {spools_updated} spool(s) using G-code data")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Fallback: linear interpolation (if no G-code data available)
|
|
|
|
|
+ if not total_layers or total_layers <= 0:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Cannot use linear fallback: total_layers={total_layers}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ progress_ratio = min(current_layer / total_layers, 1.0)
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Falling back to linear interpolation ({progress_ratio:.1%})")
|
|
|
|
|
+
|
|
|
|
|
+ for usage in filament_usage:
|
|
|
|
|
+ slot_id = usage.get("slot_id", 0)
|
|
|
|
|
+ total_used_g = usage.get("used_g", 0)
|
|
|
|
|
+
|
|
|
|
|
+ if total_used_g <= 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Calculate partial usage using linear interpolation
|
|
|
|
|
+ partial_used_g = round(total_used_g * progress_ratio, 2)
|
|
|
|
|
+
|
|
|
|
|
+ if partial_used_g <= 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Determine which tray was used for this slot
|
|
|
|
|
+ global_tray_id = slot_id - 1
|
|
|
|
|
+ if slot_to_tray and slot_id <= len(slot_to_tray):
|
|
|
|
|
+ mapped_tray = slot_to_tray[slot_id - 1]
|
|
|
|
|
+ if mapped_tray >= 0:
|
|
|
|
|
+ global_tray_id = mapped_tray
|
|
|
|
|
+
|
|
|
|
|
+ tray_info = ams_trays.get(global_tray_id)
|
|
|
|
|
+ if not tray_info:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Get spool identifier
|
|
|
|
|
+ tray_uuid = tray_info.get("tray_uuid", "")
|
|
|
|
|
+ tag_uid = tray_info.get("tag_uid", "")
|
|
|
|
|
+ spool_tag = (
|
|
|
|
|
+ tray_uuid
|
|
|
|
|
+ if tray_uuid and tray_uuid != "00000000000000000000000000000000"
|
|
|
|
|
+ else tag_uid
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if not spool_tag:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Find and update spool
|
|
|
|
|
+ spool = await client.find_spool_by_tag(spool_tag)
|
|
|
|
|
+ if spool:
|
|
|
|
|
+ result = await client.use_spool(spool["id"], partial_used_g)
|
|
|
|
|
+ if result:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ f"[SPOOLMAN] Partial (linear): slot {slot_id}: {partial_used_g}g/{total_used_g}g -> spool {spool['id']}"
|
|
|
|
|
+ )
|
|
|
|
|
+ spools_updated += 1
|
|
|
|
|
+
|
|
|
|
|
+ if spools_updated > 0:
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Reported partial usage to {spools_updated} spool(s) using linear interpolation")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _report_spoolman_usage(printer_id: int, archive_id: int, logger):
|
|
|
|
|
+ """Report filament usage to Spoolman after print completion.
|
|
|
|
|
+
|
|
|
|
|
+ Uses per-filament usage data captured at print start to report
|
|
|
|
|
+ usage to the correct spools.
|
|
|
|
|
+ """
|
|
|
|
|
+ async with async_session() as db:
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+ from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
|
|
|
|
+
|
|
|
|
|
+ # Get tracking data stored at print start
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(ActivePrintSpoolman)
|
|
|
|
|
+ .where(ActivePrintSpoolman.printer_id == printer_id)
|
|
|
|
|
+ .where(ActivePrintSpoolman.archive_id == archive_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ tracking = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if not tracking:
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] No tracking data for print (printer={printer_id}, archive={archive_id})")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ filament_usage = tracking.filament_usage or []
|
|
|
|
|
+ ams_trays = tracking.ams_trays or {}
|
|
|
|
|
+ slot_to_tray = tracking.slot_to_tray
|
|
|
|
|
+
|
|
|
|
|
+ # Delete tracking row (we're done with it)
|
|
|
|
|
+ await db.delete(tracking)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ if not filament_usage:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] No filament usage data for archive {archive_id}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Check if Spoolman is enabled
|
|
|
|
|
+ spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
|
|
|
|
+ if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Get Spoolman client
|
|
|
|
|
+ client = await get_spoolman_client()
|
|
|
|
|
+ if not client:
|
|
|
|
|
+ spoolman_url = await get_setting(db, "spoolman_url")
|
|
|
|
|
+ if spoolman_url:
|
|
|
|
|
+ client = await init_spoolman_client(spoolman_url)
|
|
|
|
|
+
|
|
|
|
|
+ if not client or not await client.health_check():
|
|
|
|
|
+ logger.warning("[SPOOLMAN] Not reachable for usage reporting")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Reporting per-filament usage for archive {archive_id}")
|
|
|
|
|
+
|
|
|
|
|
+ # Convert ams_trays keys from string to int (JSON serialization converts int keys to strings)
|
|
|
|
|
+ ams_trays = {int(k): v for k, v in ams_trays.items()}
|
|
|
|
|
+
|
|
|
|
|
+ spools_updated = 0
|
|
|
|
|
+ for usage in filament_usage:
|
|
|
|
|
+ slot_id = usage.get("slot_id", 0)
|
|
|
|
|
+ used_g = usage.get("used_g", 0)
|
|
|
|
|
+
|
|
|
|
|
+ if used_g <= 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Determine which tray was used for this slot
|
|
|
|
|
+ # Default: slot_id 1 -> global_tray_id 0, etc.
|
|
|
|
|
+ global_tray_id = slot_id - 1
|
|
|
|
|
+
|
|
|
|
|
+ # Apply custom mapping if provided
|
|
|
|
|
+ if slot_to_tray and slot_id <= len(slot_to_tray):
|
|
|
|
|
+ mapped_tray = slot_to_tray[slot_id - 1]
|
|
|
|
|
+ if mapped_tray >= 0: # -1 = unmapped
|
|
|
|
|
+ global_tray_id = mapped_tray
|
|
|
|
|
+
|
|
|
|
|
+ tray_info = ams_trays.get(global_tray_id)
|
|
|
|
|
+ if not tray_info:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Slot {slot_id}: no tray at global_tray_id {global_tray_id}")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Get spool identifier (prefer tray_uuid over tag_uid)
|
|
|
|
|
+ tray_uuid = tray_info.get("tray_uuid", "")
|
|
|
|
|
+ tag_uid = tray_info.get("tag_uid", "")
|
|
|
|
|
+ spool_tag = (
|
|
|
|
|
+ tray_uuid if tray_uuid and tray_uuid != "00000000000000000000000000000000" else tag_uid
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if not spool_tag:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Slot {slot_id}: no identifier for tray {global_tray_id}")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Find and update spool
|
|
|
|
|
+ spool = await client.find_spool_by_tag(spool_tag)
|
|
|
|
|
+ if spool:
|
|
|
|
|
+ spool_result = await client.use_spool(spool["id"], used_g)
|
|
|
|
|
+ if spool_result:
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Slot {slot_id}: {used_g}g -> spool {spool['id']} (tray {global_tray_id})")
|
|
|
|
|
+ spools_updated += 1
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Slot {slot_id}: no spool for tag {spool_tag[:16]}...")
|
|
|
|
|
|
|
|
if spools_updated == 0:
|
|
if spools_updated == 0:
|
|
|
- logger.debug(f"No matching Spoolman spools found for printer {printer_id}")
|
|
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Archive {archive_id}: no spools updated")
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.info(f"[SPOOLMAN] Archive {archive_id}: updated {spools_updated} spool(s)")
|
|
|
|
|
|
|
|
|
|
|
|
|
async def on_printer_status_change(printer_id: int, state: PrinterState):
|
|
async def on_printer_status_change(printer_id: int, state: PrinterState):
|
|
@@ -611,6 +1002,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|
|
if sync_mode and sync_mode != "auto":
|
|
if sync_mode and sync_mode != "auto":
|
|
|
return # Only sync on auto mode
|
|
return # Only sync on auto mode
|
|
|
|
|
|
|
|
|
|
+ # Check if weight sync is disabled
|
|
|
|
|
+ disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
|
|
|
|
|
+ disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
|
|
|
|
|
+
|
|
|
# Get Spoolman URL
|
|
# Get Spoolman URL
|
|
|
spoolman_url = await get_setting(db, "spoolman_url")
|
|
spoolman_url = await get_setting(db, "spoolman_url")
|
|
|
if not spoolman_url:
|
|
if not spoolman_url:
|
|
@@ -643,7 +1038,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|
|
continue # Empty tray
|
|
continue # Empty tray
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- result = await client.sync_ams_tray(tray, printer_name)
|
|
|
|
|
|
|
+ result = await client.sync_ams_tray(tray, printer_name, disable_weight_sync=disable_weight_sync)
|
|
|
if result:
|
|
if result:
|
|
|
synced += 1
|
|
synced += 1
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
@@ -1008,6 +1403,12 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
# Extract printable objects from the archived 3MF file
|
|
# Extract printable objects from the archived 3MF file
|
|
|
_load_objects_from_archive(archive, printer_id, logger)
|
|
_load_objects_from_archive(archive, printer_id, logger)
|
|
|
|
|
|
|
|
|
|
+ # Store Spoolman tracking data for per-filament usage reporting
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, logger)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning(f"[SPOOLMAN] Failed to store tracking data: {e}")
|
|
|
|
|
+
|
|
|
return # Skip creating a new archive
|
|
return # Skip creating a new archive
|
|
|
|
|
|
|
|
# Check if there's already a "printing" archive for this printer/file
|
|
# Check if there's already a "printing" archive for this printer/file
|
|
@@ -1302,6 +1703,14 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass # Don't fail if MQTT fails
|
|
pass # Don't fail if MQTT fails
|
|
|
|
|
|
|
|
|
|
+ # Store Spoolman tracking data (may not work for fallback since no 3MF)
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _store_spoolman_print_data(
|
|
|
|
|
+ printer_id, fallback_archive.id, fallback_archive.file_path, db, logger
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Could not store tracking for fallback archive: {e}")
|
|
|
|
|
+
|
|
|
# Send notification without archive data (file not found)
|
|
# Send notification without archive data (file not found)
|
|
|
if not notification_sent:
|
|
if not notification_sent:
|
|
|
await _send_print_start_notification(printer_id, data, logger=logger)
|
|
await _send_print_start_notification(printer_id, data, logger=logger)
|
|
@@ -1413,6 +1822,12 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
|
|
logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.debug(f"Failed to extract printable objects: {e}")
|
|
logger.debug(f"Failed to extract printable objects: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ # Store Spoolman tracking data for per-filament usage reporting
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, logger)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning(f"[SPOOLMAN] Failed to store tracking data: {e}")
|
|
|
finally:
|
|
finally:
|
|
|
if temp_path and temp_path.exists():
|
|
if temp_path and temp_path.exists():
|
|
|
temp_path.unlink()
|
|
temp_path.unlink()
|
|
@@ -1764,6 +2179,13 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
log_timing("Spoolman usage report")
|
|
log_timing("Spoolman usage report")
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning(f"Spoolman usage reporting failed: {e}")
|
|
logger.warning(f"Spoolman usage reporting failed: {e}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Report partial usage if tracking data exists (only stored when weight sync is disabled)
|
|
|
|
|
+ try:
|
|
|
|
|
+ async with async_session() as db:
|
|
|
|
|
+ await _cleanup_spoolman_tracking(printer_id, archive_id, db, logger)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.debug(f"[SPOOLMAN] Cleanup failed: {e}")
|
|
|
|
|
|
|
|
# Run slow operations as background tasks to avoid blocking the event loop
|
|
# Run slow operations as background tasks to avoid blocking the event loop
|
|
|
# These operations can take 5-10+ seconds and would freeze the UI if awaited
|
|
# These operations can take 5-10+ seconds and would freeze the UI if awaited
|