diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index 976aedd76..b90907812 100644
--- a/backend/app/api/routes/settings.py
+++ b/backend/app/api/routes/settings.py
@@ -64,6 +64,8 @@ async def get_settings(
"save_thumbnails",
"capture_finish_photo",
"spoolman_enabled",
+ "spoolman_disable_weight_sync",
+ "spoolman_report_partial_usage",
"check_updates",
"check_printer_firmware",
"virtual_printer_enabled",
@@ -208,11 +210,15 @@ async def get_spoolman_settings(
spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
spoolman_url = await get_setting(db, "spoolman_url") or ""
spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
+ spoolman_disable_weight_sync = await get_setting(db, "spoolman_disable_weight_sync") or "false"
+ spoolman_report_partial_usage = await get_setting(db, "spoolman_report_partial_usage") or "true"
return {
"spoolman_enabled": spoolman_enabled,
"spoolman_url": spoolman_url,
"spoolman_sync_mode": spoolman_sync_mode,
+ "spoolman_disable_weight_sync": spoolman_disable_weight_sync,
+ "spoolman_report_partial_usage": spoolman_report_partial_usage,
}
@@ -229,6 +235,10 @@ async def update_spoolman_settings(
await set_setting(db, "spoolman_url", settings["spoolman_url"])
if "spoolman_sync_mode" in settings:
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
+ if "spoolman_disable_weight_sync" in settings:
+ await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
+ if "spoolman_report_partial_usage" in settings:
+ await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
await db.commit()
db.expire_all()
diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py
index 9d5ec4f47..540ccfc6e 100644
--- a/backend/app/api/routes/spoolman.py
+++ b/backend/app/api/routes/spoolman.py
@@ -52,15 +52,16 @@ class SyncResult(BaseModel):
errors: list[str]
-async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
+async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str, bool]:
"""Get Spoolman settings from database.
Returns:
- Tuple of (enabled, url, sync_mode)
+ Tuple of (enabled, url, sync_mode, disable_weight_sync)
"""
enabled = False
url = ""
sync_mode = "auto"
+ disable_weight_sync = False
result = await db.execute(select(Settings))
for setting in result.scalars().all():
@@ -70,8 +71,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
url = setting.value
elif setting.key == "spoolman_sync_mode":
sync_mode = setting.value
+ elif setting.key == "spoolman_disable_weight_sync":
+ disable_weight_sync = setting.value.lower() == "true"
- return enabled, url, sync_mode
+ return enabled, url, sync_mode, disable_weight_sync
@router.get("/status", response_model=SpoolmanStatus)
@@ -80,7 +83,7 @@ async def get_spoolman_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get Spoolman integration status."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
client = await get_spoolman_client()
connected = False
@@ -100,7 +103,7 @@ async def connect_spoolman(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Connect to Spoolman server using configured URL."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -144,7 +147,7 @@ async def sync_printer_ams(
):
"""Sync AMS data from a specific printer to Spoolman."""
# Check if Spoolman is enabled and connected
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, disable_weight_sync = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -249,7 +252,7 @@ async def sync_printer_ams(
current_tray_uuids.add(spool_tag.upper())
try:
- sync_result = await client.sync_ams_tray(tray, printer.name)
+ sync_result = await client.sync_ams_tray(tray, printer.name, disable_weight_sync=disable_weight_sync)
if sync_result:
synced += 1
logger.info(f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}")
@@ -285,7 +288,7 @@ async def sync_all_printers(
):
"""Sync AMS data from all connected printers to Spoolman."""
# Check if Spoolman is enabled
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, disable_weight_sync = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -382,7 +385,9 @@ async def sync_all_printers(
printer_tray_uuids[printer.name].add(spool_tag.upper())
try:
- sync_result = await client.sync_ams_tray(tray, printer.name)
+ sync_result = await client.sync_ams_tray(
+ tray, printer.name, disable_weight_sync=disable_weight_sync
+ )
if sync_result:
total_synced += 1
except Exception as e:
@@ -412,7 +417,7 @@ async def get_spools(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all spools from Spoolman."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -436,7 +441,7 @@ async def get_filaments(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all filaments from Spoolman."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -471,7 +476,7 @@ async def get_unlinked_spools(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all Spoolman spools that don't have a tag (not linked to AMS)."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -516,7 +521,7 @@ async def get_linked_spools(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -560,7 +565,7 @@ async def link_spool(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
- enabled, url, _ = await get_spoolman_settings(db)
+ enabled, url, _, _ = await get_spoolman_settings(db)
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 6c97bf120..ce7d44dd7 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -54,6 +54,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import ( # noqa: F401
+ active_print_spoolman,
ams_history,
api_key,
archive,
@@ -1080,6 +1081,26 @@ async def run_migrations(conn):
except Exception:
pass
+ # Create active_print_spoolman table for Spoolman per-filament tracking
+ try:
+ await conn.execute(
+ text("""
+ CREATE TABLE IF NOT EXISTS active_print_spoolman (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
+ archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+ filament_usage TEXT NOT NULL,
+ ams_trays TEXT NOT NULL,
+ slot_to_tray TEXT,
+ layer_usage TEXT,
+ filament_properties TEXT,
+ UNIQUE(printer_id, archive_id)
+ )
+ """)
+ )
+ except Exception:
+ pass
+
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
diff --git a/backend/app/main.py b/backend/app/main.py
index db4b52564..a226e0569 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,4 +1,5 @@
import asyncio
+import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
@@ -294,85 +295,464 @@ _last_status_broadcast: dict[int, str] = {}
_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.
- This finds the spool by RFID tag_uid from current AMS state and reports
- the filament_used_grams from the archive metadata.
+ 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("[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}")
+
+
+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:
- 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
spoolman_enabled = await get_setting(db, "spoolman_enabled")
if not spoolman_enabled or spoolman_enabled.lower() != "true":
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()
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
- # 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
-
- filament_used = archive.filament_used_grams
- logger.info(f"[SPOOLMAN] Archive {archive_id} used {filament_used}g of filament")
-
- # 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
-
- ams_data = state.raw_data.get("ams")
- if not ams_data:
- logger.debug("No AMS data available for usage reporting")
- return
-
- # 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", [])
- for tray_data in trays:
- tag_uid = tray_data.get("tag_uid")
- if not tag_uid:
- continue
+ # 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 cumulative usage at current layer
+ usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
+
+ if usage_mm:
+ logger.info(f"[SPOOLMAN] Using G-code parsed data for layer {current_layer}")
+
+ # 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
+
+ # 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
- result = await client.use_spool(spool["id"], filament_used)
+ result = await client.use_spool(spool["id"], grams_used)
if result:
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
- # 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:
- 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):
@@ -611,6 +991,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
if sync_mode and sync_mode != "auto":
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
spoolman_url = await get_setting(db, "spoolman_url")
if not spoolman_url:
@@ -643,7 +1027,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
continue # Empty tray
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:
synced += 1
except Exception as e:
@@ -1008,6 +1392,12 @@ async def on_print_start(printer_id: int, data: dict):
# Extract printable objects from the archived 3MF file
_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
# Check if there's already a "printing" archive for this printer/file
@@ -1302,6 +1692,14 @@ async def on_print_start(printer_id: int, data: dict):
except Exception:
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)
if not notification_sent:
await _send_print_start_notification(printer_id, data, logger=logger)
@@ -1413,6 +1811,12 @@ async def on_print_start(printer_id: int, data: dict):
logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
except Exception as 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:
if temp_path and temp_path.exists():
temp_path.unlink()
@@ -1764,6 +2168,13 @@ async def on_print_complete(printer_id: int, data: dict):
log_timing("Spoolman usage report")
except Exception as 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
# These operations can take 5-10+ seconds and would freeze the UI if awaited
diff --git a/backend/app/models/active_print_spoolman.py b/backend/app/models/active_print_spoolman.py
new file mode 100644
index 000000000..51cf83039
--- /dev/null
+++ b/backend/app/models/active_print_spoolman.py
@@ -0,0 +1,42 @@
+"""Track Spoolman data for active prints."""
+
+from sqlalchemy import JSON, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ActivePrintSpoolman(Base):
+ """Stores Spoolman tracking data for active prints.
+
+ This data is captured at print start and used at print completion
+ to report per-filament usage to the correct Spoolman spools.
+ Rows are deleted after print completes.
+
+ Key: (printer_id, archive_id) - allows same archive on different printers
+ """
+
+ __tablename__ = "active_print_spoolman"
+ __table_args__ = (UniqueConstraint("printer_id", "archive_id", name="uq_printer_archive"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+ archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
+
+ # Per-filament usage from 3MF: [{"slot_id": 1, "used_g": 50.5, "type": "PLA"}, ...]
+ filament_usage: Mapped[list] = mapped_column(JSON)
+
+ # AMS tray state at print start: {0: {"tray_uuid": "...", "tag_uid": "..."}, ...}
+ ams_trays: Mapped[dict] = mapped_column(JSON)
+
+ # Custom slot-to-tray mapping from queue (optional): [5, -1, 2, -1]
+ slot_to_tray: Mapped[list | None] = mapped_column(JSON, nullable=True)
+
+ # Per-layer cumulative usage from G-code parsing (for accurate partial usage)
+ # Format: {"0": {0: 125.5}, "1": {0: 250.0, 1: 50.0}, ...}
+ # Keys are layer numbers (as strings for JSON), values are filament_id -> mm
+ layer_usage: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+ # Filament properties (density, diameter per filament slot)
+ # Format: {1: {"density": 1.24, "diameter": 1.75, "type": "PLA"}, ...}
+ filament_properties: Mapped[dict | None] = mapped_column(JSON, nullable=True)
diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py
index 228dd2e13..cc6a52190 100644
--- a/backend/app/schemas/settings.py
+++ b/backend/app/schemas/settings.py
@@ -23,6 +23,14 @@ class AppSettings(BaseModel):
spoolman_sync_mode: str = Field(
default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press"
)
+ spoolman_disable_weight_sync: bool = Field(
+ default=False,
+ description="Disable remaining_weight sync. When enabled, only location is updated for existing spools.",
+ )
+ spoolman_report_partial_usage: bool = Field(
+ default=True,
+ description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
+ )
# Updates
check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -135,6 +143,8 @@ class AppSettingsUpdate(BaseModel):
spoolman_enabled: bool | None = None
spoolman_url: str | None = None
spoolman_sync_mode: str | None = None
+ spoolman_disable_weight_sync: bool | None = None
+ spoolman_report_partial_usage: bool | None = None
check_updates: bool | None = None
check_printer_firmware: bool | None = None
notification_language: str | None = None
diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py
index 21f8ed3f0..94413720e 100644
--- a/backend/app/services/spoolman.py
+++ b/backend/app/services/spoolman.py
@@ -662,7 +662,7 @@ class SpoolmanClient:
"""
return (remain_percent / 100.0) * spool_weight
- async def sync_ams_tray(self, tray: AMSTray, printer_name: str) -> dict | None:
+ async def sync_ams_tray(self, tray: AMSTray, printer_name: str, disable_weight_sync: bool = False) -> dict | None:
"""Sync a single AMS tray to Spoolman.
Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
@@ -674,6 +674,8 @@ class SpoolmanClient:
Args:
tray: The AMSTray to sync
printer_name: Name of the printer for location
+ disable_weight_sync: If True, skip updating remaining_weight for existing spools.
+ This allows Spoolman's granular usage tracking to maintain accurate weights.
Returns:
Synced spool dictionary or None if skipped or failed.
@@ -720,7 +722,7 @@ class SpoolmanClient:
logger.info(f"Updating existing spool {existing['id']} for tag {spool_tag[:16]}...")
return await self.update_spool(
spool_id=existing["id"],
- remaining_weight=remaining,
+ remaining_weight=None if disable_weight_sync else remaining,
location=location,
)
diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py
new file mode 100644
index 000000000..9987d4ba0
--- /dev/null
+++ b/backend/app/utils/threemf_tools.py
@@ -0,0 +1,307 @@
+"""3MF file parsing utilities for filament tracking.
+
+This module provides functions to parse Bambu Lab 3MF files and extract
+per-layer filament usage data from the embedded G-code. This enables
+accurate partial usage reporting for multi-material prints.
+"""
+
+import json
+import math
+import re
+import zipfile
+from pathlib import Path
+import defusedxml.ElementTree as ET
+
+# Default filament properties
+DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
+DEFAULT_FILAMENT_DENSITY = 1.24 # g/cm³ (PLA)
+
+
+def parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:
+ """Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.
+
+ This function tracks filament extrusion across layers and tool changes,
+ building a cumulative usage map that can be used to calculate partial
+ usage at any layer.
+
+ Args:
+ gcode_content: The raw G-code content as a string
+
+ Returns:
+ A nested dictionary mapping layer numbers to filament usage:
+ {layer: {filament_id: cumulative_mm}, ...}
+
+ Example:
+ {0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}
+
+ This shows:
+ - Layer 0: filament 0 used 125.5mm cumulative
+ - Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm
+ - Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm
+
+ G-code commands parsed:
+ - M73 L
Disable AMS Estimated Weight Sync
++ Don't update remaining capacity from AMS estimates. Use this if you prefer + Spoolman's usage tracking over AMS percentage-based estimates. New spools + will still use the AMS estimate as their initial weight. +
+Report Partial Usage for Failed Prints
++ When a print fails or is cancelled, report the estimated filament used + up to that point based on layer progress. +
+