mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 20:56:18 +02:00
Merge pull request #277 from bambuman/feature/accurate-usage-tracking
Feature/accurate usage tracking
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+466
-55
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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<layer>: Layer change marker
|
||||
- M620 S<filament>: Filament/tool change (S255 = unload)
|
||||
- G0/G1/G2/G3 E<amount>: Extrusion moves
|
||||
"""
|
||||
layer_filaments: dict[int, dict[int, float]] = {}
|
||||
current_layer = 0
|
||||
active_filament: int | None = None
|
||||
cumulative_extrusion: dict[int, float] = {} # filament_id -> total mm
|
||||
|
||||
for line in gcode_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Handle comments - skip but check for layer markers
|
||||
if line.startswith(";"):
|
||||
# Some slicers use comment-based layer markers
|
||||
# e.g., "; CHANGE_LAYER" or ";LAYER_CHANGE"
|
||||
continue
|
||||
|
||||
# Split line into command and inline comment
|
||||
if ";" in line:
|
||||
line = line.split(";")[0].strip()
|
||||
|
||||
# Extract command and parameters
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
cmd = parts[0].upper()
|
||||
|
||||
# Layer change: M73 L<layer>
|
||||
# Bambu printers use M73 with L parameter for layer indication
|
||||
if cmd == "M73":
|
||||
for part in parts[1:]:
|
||||
part_upper = part.upper()
|
||||
if part_upper.startswith("L"):
|
||||
try:
|
||||
new_layer = int(part[1:])
|
||||
# Save current state before layer change
|
||||
if cumulative_extrusion:
|
||||
layer_filaments[current_layer] = cumulative_extrusion.copy()
|
||||
current_layer = new_layer
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Filament change: M620 S<filament>
|
||||
# Bambu uses M620 for AMS filament switching
|
||||
# S255 means full unload (no active filament)
|
||||
elif cmd == "M620":
|
||||
for part in parts[1:]:
|
||||
part_upper = part.upper()
|
||||
if part_upper.startswith("S"):
|
||||
filament_str = part[1:]
|
||||
if filament_str == "255":
|
||||
# Full unload - no active filament
|
||||
active_filament = None
|
||||
else:
|
||||
try:
|
||||
# Extract digits (e.g., "0A" -> 0, "1" -> 1)
|
||||
match = re.match(r"(\d+)", filament_str)
|
||||
if match:
|
||||
active_filament = int(match.group(1))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Extrusion moves: G0/G1/G2/G3 with E parameter
|
||||
# Only G1 typically has extrusion, but check all for safety
|
||||
elif cmd in ("G0", "G1", "G2", "G3"):
|
||||
if active_filament is None:
|
||||
continue
|
||||
for part in parts[1:]:
|
||||
part_upper = part.upper()
|
||||
if part_upper.startswith("E"):
|
||||
try:
|
||||
extrusion = float(part[1:])
|
||||
# Only count positive extrusion (not retractions)
|
||||
if extrusion > 0:
|
||||
current = cumulative_extrusion.get(active_filament, 0)
|
||||
cumulative_extrusion[active_filament] = current + extrusion
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Save final layer state
|
||||
if cumulative_extrusion:
|
||||
layer_filaments[current_layer] = cumulative_extrusion.copy()
|
||||
|
||||
return layer_filaments
|
||||
|
||||
|
||||
def mm_to_grams(
|
||||
length_mm: float,
|
||||
diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,
|
||||
density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,
|
||||
) -> float:
|
||||
"""Convert filament length in mm to weight in grams.
|
||||
|
||||
Uses the formula: mass = volume × density
|
||||
where volume = π × r² × length
|
||||
|
||||
Args:
|
||||
length_mm: Length of filament in millimeters
|
||||
diameter_mm: Filament diameter in millimeters (default: 1.75)
|
||||
density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)
|
||||
|
||||
Returns:
|
||||
Weight in grams
|
||||
"""
|
||||
radius_cm = (diameter_mm / 2) / 10 # Convert mm to cm
|
||||
length_cm = length_mm / 10 # Convert mm to cm
|
||||
volume_cm3 = math.pi * radius_cm * radius_cm * length_cm
|
||||
return volume_cm3 * density_g_cm3
|
||||
|
||||
|
||||
def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int, float]] | None:
|
||||
"""Extract per-layer filament usage from a 3MF file's embedded G-code.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
|
||||
Returns:
|
||||
Dictionary mapping layers to filament usage, or None if parsing fails.
|
||||
Format: {layer: {filament_id: cumulative_mm}, ...}
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
# Find G-code file(s) - usually plate_1.gcode or Metadata/plate_1.gcode
|
||||
gcode_files = [f for f in zf.namelist() if f.endswith(".gcode")]
|
||||
if not gcode_files:
|
||||
return None
|
||||
|
||||
# Use the first G-code file (typically only one per 3MF export)
|
||||
gcode_path = gcode_files[0]
|
||||
gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
|
||||
|
||||
return parse_gcode_layer_filament_usage(gcode_content)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_cumulative_usage_at_layer(
|
||||
layer_usage: dict[int, dict[int, float]],
|
||||
target_layer: int,
|
||||
) -> dict[int, float]:
|
||||
"""Get cumulative filament usage (in mm) up to and including target_layer.
|
||||
|
||||
Args:
|
||||
layer_usage: The output from parse_gcode_layer_filament_usage()
|
||||
target_layer: The layer number to get usage for
|
||||
|
||||
Returns:
|
||||
Dictionary of {filament_id: cumulative_mm} for each filament used
|
||||
up to target_layer. Returns empty dict if no data available.
|
||||
"""
|
||||
if not layer_usage:
|
||||
return {}
|
||||
|
||||
# Find the highest recorded layer <= target_layer
|
||||
# (we store snapshots at layer changes, so we need the closest one)
|
||||
relevant_layers = [layer for layer in layer_usage if layer <= target_layer]
|
||||
if not relevant_layers:
|
||||
return {}
|
||||
|
||||
max_layer = max(relevant_layers)
|
||||
return layer_usage.get(max_layer, {})
|
||||
|
||||
|
||||
def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
|
||||
"""Extract filament properties (density, diameter, type) from 3MF metadata.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
|
||||
Returns:
|
||||
Dictionary mapping filament IDs to their properties:
|
||||
{filament_id: {"diameter": 1.75, "density": 1.24, "type": "PLA"}, ...}
|
||||
|
||||
Note: filament_id is 1-based (matches slot_id in slice_info.config)
|
||||
"""
|
||||
properties: dict[int, dict] = {}
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
# Try slice_info.config first for filament types
|
||||
if "Metadata/slice_info.config" in zf.namelist():
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
for f in root.findall(".//filament"):
|
||||
try:
|
||||
# id is 1-based in slice_info.config
|
||||
fid = int(f.get("id", 0))
|
||||
properties[fid] = {
|
||||
"type": f.get("type", "PLA"),
|
||||
"diameter": DEFAULT_FILAMENT_DIAMETER,
|
||||
"density": DEFAULT_FILAMENT_DENSITY,
|
||||
}
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try project_settings.config for density values
|
||||
if "Metadata/project_settings.config" in zf.namelist():
|
||||
content = zf.read("Metadata/project_settings.config").decode()
|
||||
try:
|
||||
data = json.loads(content)
|
||||
densities = data.get("filament_density", [])
|
||||
for i, density in enumerate(densities):
|
||||
# project_settings uses 0-based indexing, convert to 1-based
|
||||
fid = i + 1
|
||||
if fid not in properties:
|
||||
properties[fid] = {
|
||||
"type": "",
|
||||
"diameter": DEFAULT_FILAMENT_DIAMETER,
|
||||
}
|
||||
try:
|
||||
properties[fid]["density"] = float(density)
|
||||
except (ValueError, TypeError):
|
||||
properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return properties
|
||||
|
||||
|
||||
def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
|
||||
"""Extract per-filament total usage from 3MF slice_info.config.
|
||||
|
||||
This extracts the slicer-estimated total usage per filament slot,
|
||||
not the per-layer breakdown.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
|
||||
Returns:
|
||||
List of filament usage dictionaries:
|
||||
[{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
|
||||
"""
|
||||
filament_usage = []
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
if "Metadata/slice_info.config" not in zf.namelist():
|
||||
return []
|
||||
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
for f in root.findall(".//filament"):
|
||||
filament_id = f.get("id")
|
||||
used_g = f.get("used_g", "0")
|
||||
try:
|
||||
used_amount = float(used_g)
|
||||
if filament_id:
|
||||
filament_usage.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"used_g": used_amount,
|
||||
"type": f.get("type", ""),
|
||||
"color": f.get("color", ""),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return filament_usage
|
||||
@@ -499,3 +499,209 @@ class TestSpoolmanAPI:
|
||||
assert isinstance(data["filaments"], list)
|
||||
assert len(data["filaments"]) == 1
|
||||
assert data["filaments"][0]["name"] == "PLA Basic"
|
||||
|
||||
# =========================================================================
|
||||
# Disable Weight Sync Tests
|
||||
# =========================================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def spoolman_settings_weight_sync_disabled(self, db_session):
|
||||
"""Create Spoolman settings with weight sync disabled."""
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
enabled_setting = Settings(key="spoolman_enabled", value="true")
|
||||
url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
|
||||
disable_weight_setting = Settings(key="spoolman_disable_weight_sync", value="true")
|
||||
partial_usage_setting = Settings(key="spoolman_report_partial_usage", value="true")
|
||||
db_session.add(enabled_setting)
|
||||
db_session.add(url_setting)
|
||||
db_session.add(disable_weight_setting)
|
||||
db_session.add(partial_usage_setting)
|
||||
await db_session.commit()
|
||||
return {
|
||||
"enabled": enabled_setting,
|
||||
"url": url_setting,
|
||||
"disable_weight": disable_weight_setting,
|
||||
"partial_usage": partial_usage_setting,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_settings_returns_disable_weight_sync(
|
||||
self, async_client: AsyncClient, spoolman_settings_weight_sync_disabled
|
||||
):
|
||||
"""Verify settings endpoint returns the disable_weight_sync setting."""
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "spoolman_disable_weight_sync" in data
|
||||
assert data["spoolman_disable_weight_sync"] == "true"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_settings_update_disable_weight_sync(self, async_client: AsyncClient, spoolman_settings):
|
||||
"""Verify settings endpoint can update the disable_weight_sync setting."""
|
||||
# First verify it's false by default
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data.get("spoolman_disable_weight_sync", "false") == "false"
|
||||
|
||||
# Update the setting
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/spoolman",
|
||||
json={"spoolman_disable_weight_sync": "true"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["spoolman_disable_weight_sync"] == "true"
|
||||
|
||||
# Verify it persisted
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["spoolman_disable_weight_sync"] == "true"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_sync_with_weight_sync_disabled_updates_location_only(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
spoolman_settings_weight_sync_disabled,
|
||||
mock_spoolman_client,
|
||||
printer_factory,
|
||||
):
|
||||
"""Verify sync only updates location when disable_weight_sync is enabled."""
|
||||
printer = await printer_factory()
|
||||
|
||||
# Mock existing spool
|
||||
mock_existing_spool = {
|
||||
"id": 42,
|
||||
"remaining_weight": 800,
|
||||
"extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
|
||||
"filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
|
||||
}
|
||||
mock_spoolman_client.find_spool_by_tag = AsyncMock(return_value=mock_existing_spool)
|
||||
mock_spoolman_client.parse_ams_tray = MagicMock()
|
||||
|
||||
# Create mock AMSTray
|
||||
from backend.app.services.spoolman import AMSTray
|
||||
|
||||
mock_tray = AMSTray(
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
tray_type="PLA",
|
||||
tray_sub_brands="PLA Basic",
|
||||
tray_color="FF0000FF",
|
||||
remain=50,
|
||||
tag_uid="",
|
||||
tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
|
||||
tray_info_idx="GFA00",
|
||||
tray_weight=1000,
|
||||
)
|
||||
mock_spoolman_client.parse_ams_tray.return_value = mock_tray
|
||||
mock_spoolman_client.is_bambu_lab_spool = MagicMock(return_value=True)
|
||||
mock_spoolman_client.convert_ams_slot_to_location = MagicMock(return_value="AMS A1")
|
||||
mock_spoolman_client.sync_ams_tray = AsyncMock(return_value={"id": 42})
|
||||
mock_spoolman_client.clear_location_for_removed_spools = AsyncMock(return_value=0)
|
||||
|
||||
with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
|
||||
mock_state = MagicMock()
|
||||
mock_state.raw_data = {
|
||||
"ams": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray_type": "PLA",
|
||||
"tray_sub_brands": "PLA Basic",
|
||||
"tray_color": "FF0000FF",
|
||||
"remain": 50,
|
||||
"tag_uid": "",
|
||||
"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
|
||||
"tray_info_idx": "GFA00",
|
||||
"tray_weight": 1000,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
pm_mock.get_status = MagicMock(return_value=mock_state)
|
||||
|
||||
response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify sync_ams_tray was called with disable_weight_sync=True
|
||||
mock_spoolman_client.sync_ams_tray.assert_called()
|
||||
call_kwargs = mock_spoolman_client.sync_ams_tray.call_args.kwargs
|
||||
assert call_kwargs.get("disable_weight_sync") is True
|
||||
|
||||
# =========================================================================
|
||||
# Report Partial Usage Tests
|
||||
# =========================================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def spoolman_settings_partial_usage_disabled(self, db_session):
|
||||
"""Create Spoolman settings with partial usage reporting disabled."""
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
enabled_setting = Settings(key="spoolman_enabled", value="true")
|
||||
url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
|
||||
partial_usage_setting = Settings(key="spoolman_report_partial_usage", value="false")
|
||||
db_session.add(enabled_setting)
|
||||
db_session.add(url_setting)
|
||||
db_session.add(partial_usage_setting)
|
||||
await db_session.commit()
|
||||
return {
|
||||
"enabled": enabled_setting,
|
||||
"url": url_setting,
|
||||
"partial_usage": partial_usage_setting,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_settings_returns_report_partial_usage(
|
||||
self, async_client: AsyncClient, spoolman_settings_partial_usage_disabled
|
||||
):
|
||||
"""Verify settings endpoint returns the report_partial_usage setting."""
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "spoolman_report_partial_usage" in data
|
||||
assert data["spoolman_report_partial_usage"] == "false"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_settings_update_report_partial_usage(self, async_client: AsyncClient, spoolman_settings):
|
||||
"""Verify settings endpoint can update the report_partial_usage setting."""
|
||||
# First verify it's true by default
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data.get("spoolman_report_partial_usage", "true") == "true"
|
||||
|
||||
# Update the setting to false
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/spoolman",
|
||||
json={"spoolman_report_partial_usage": "false"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["spoolman_report_partial_usage"] == "false"
|
||||
|
||||
# Verify it persisted
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["spoolman_report_partial_usage"] == "false"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_settings_report_partial_usage_defaults_to_true(self, async_client: AsyncClient, spoolman_settings):
|
||||
"""Verify report_partial_usage defaults to true (unlike disable_weight_sync which defaults to false)."""
|
||||
response = await async_client.get("/api/v1/settings/spoolman")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should default to "true"
|
||||
assert data["spoolman_report_partial_usage"] == "true"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Unit tests for Spoolman service.
|
||||
|
||||
These tests specifically target the sync_ams_tray method's disable_weight_sync
|
||||
functionality that controls whether remaining_weight is updated.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.spoolman import AMSTray, SpoolmanClient
|
||||
|
||||
|
||||
class TestSpoolmanClient:
|
||||
"""Tests for SpoolmanClient class."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a SpoolmanClient instance."""
|
||||
return SpoolmanClient("http://localhost:7912")
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tray(self):
|
||||
"""Create a sample AMSTray for testing."""
|
||||
return AMSTray(
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
tray_type="PLA",
|
||||
tray_sub_brands="PLA Basic",
|
||||
tray_color="FF0000FF",
|
||||
remain=50,
|
||||
tag_uid="",
|
||||
tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
|
||||
tray_info_idx="GFA00",
|
||||
tray_weight=1000,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def existing_spool(self):
|
||||
"""Create a mock existing spool response."""
|
||||
return {
|
||||
"id": 42,
|
||||
"remaining_weight": 800,
|
||||
"extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
|
||||
"filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_filament(self):
|
||||
"""Create a mock filament response."""
|
||||
return {"id": 1, "name": "PLA Basic", "material": "PLA"}
|
||||
|
||||
# ========================================================================
|
||||
# Tests for sync_ams_tray with disable_weight_sync
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_ams_tray_updates_weight_by_default(self, client, sample_tray, existing_spool):
|
||||
"""Verify sync_ams_tray updates remaining_weight by default."""
|
||||
with (
|
||||
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
|
||||
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
|
||||
):
|
||||
await client.sync_ams_tray(sample_tray, "TestPrinter")
|
||||
|
||||
mock_update.assert_called_once()
|
||||
call_kwargs = mock_update.call_args.kwargs
|
||||
assert "remaining_weight" in call_kwargs
|
||||
assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
|
||||
assert "location" in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_ams_tray_skips_weight_when_disabled(self, client, sample_tray, existing_spool):
|
||||
"""Verify sync_ams_tray skips remaining_weight when disable_weight_sync=True."""
|
||||
with (
|
||||
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
|
||||
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
|
||||
):
|
||||
await client.sync_ams_tray(sample_tray, "TestPrinter", disable_weight_sync=True)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
call_kwargs = mock_update.call_args.kwargs
|
||||
# remaining_weight should be None (not updated)
|
||||
assert call_kwargs.get("remaining_weight") is None
|
||||
# location should still be updated
|
||||
assert "location" in call_kwargs
|
||||
assert "TestPrinter" in call_kwargs["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_ams_tray_new_spool_always_includes_weight(self, client, sample_tray, mock_filament):
|
||||
"""Verify new spool creation always includes remaining_weight even when disabled."""
|
||||
with (
|
||||
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
|
||||
patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
|
||||
patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
|
||||
):
|
||||
await client.sync_ams_tray(sample_tray, "TestPrinter", disable_weight_sync=True)
|
||||
|
||||
mock_create.assert_called_once()
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
# New spools should ALWAYS include remaining_weight
|
||||
assert "remaining_weight" in call_kwargs
|
||||
assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_ams_tray_location_format(self, client, sample_tray, existing_spool):
|
||||
"""Verify location format is correct when updating spool."""
|
||||
with (
|
||||
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
|
||||
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
|
||||
):
|
||||
await client.sync_ams_tray(sample_tray, "My Printer", disable_weight_sync=True)
|
||||
|
||||
call_kwargs = mock_update.call_args.kwargs
|
||||
# Location should follow pattern: "PrinterName - AMS A1"
|
||||
assert "location" in call_kwargs
|
||||
assert "My Printer" in call_kwargs["location"]
|
||||
assert "AMS" in call_kwargs["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_ams_tray_skips_non_bambu_spool(self, client):
|
||||
"""Verify non-Bambu Lab spools are skipped."""
|
||||
# Third-party spool without proper identifiers
|
||||
tray = AMSTray(
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
tray_type="PLA",
|
||||
tray_sub_brands="Third Party PLA",
|
||||
tray_color="FF0000FF",
|
||||
remain=50,
|
||||
tag_uid="",
|
||||
tray_uuid="",
|
||||
tray_info_idx="", # No Bambu Lab preset ID
|
||||
tray_weight=1000,
|
||||
)
|
||||
|
||||
result = await client.sync_ams_tray(tray, "TestPrinter")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_ams_tray_weight_calculation(self, client, existing_spool):
|
||||
"""Verify remaining weight is calculated correctly for various percentages."""
|
||||
test_cases = [
|
||||
(100, 1000, 1000.0), # Full spool
|
||||
(50, 1000, 500.0), # Half spool
|
||||
(25, 1000, 250.0), # Quarter spool
|
||||
(0, 1000, 0.0), # Empty spool
|
||||
(75, 500, 375.0), # Different spool weight
|
||||
]
|
||||
|
||||
for remain, weight, expected in test_cases:
|
||||
tray = AMSTray(
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
tray_type="PLA",
|
||||
tray_sub_brands="PLA Basic",
|
||||
tray_color="FF0000FF",
|
||||
remain=remain,
|
||||
tag_uid="",
|
||||
tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
|
||||
tray_info_idx="GFA00",
|
||||
tray_weight=weight,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
|
||||
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
|
||||
):
|
||||
await client.sync_ams_tray(tray, "TestPrinter", disable_weight_sync=False)
|
||||
|
||||
call_kwargs = mock_update.call_args.kwargs
|
||||
assert call_kwargs["remaining_weight"] == expected, (
|
||||
f"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}"
|
||||
)
|
||||
@@ -3089,9 +3089,9 @@ export const api = {
|
||||
body: JSON.stringify({ tray_uuid: trayUuid }),
|
||||
}),
|
||||
getSpoolmanSettings: () =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman'),
|
||||
updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string }) =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman', {
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman'),
|
||||
updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; }) =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,8 @@ export function SpoolmanSettings() {
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
const [localUrl, setLocalUrl] = useState('');
|
||||
const [localSyncMode, setLocalSyncMode] = useState('auto');
|
||||
const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);
|
||||
const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
@@ -41,6 +43,8 @@ export function SpoolmanSettings() {
|
||||
setLocalEnabled(settings.spoolman_enabled === 'true');
|
||||
setLocalUrl(settings.spoolman_url || '');
|
||||
setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
|
||||
setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true');
|
||||
setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false');
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [settings]);
|
||||
@@ -53,7 +57,9 @@ export function SpoolmanSettings() {
|
||||
const hasChanges =
|
||||
(settings.spoolman_enabled === 'true') !== localEnabled ||
|
||||
(settings.spoolman_url || '') !== localUrl ||
|
||||
(settings.spoolman_sync_mode || 'auto') !== localSyncMode;
|
||||
(settings.spoolman_sync_mode || 'auto') !== localSyncMode ||
|
||||
(settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync ||
|
||||
(settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage;
|
||||
|
||||
if (hasChanges) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -62,7 +68,7 @@ export function SpoolmanSettings() {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localEnabled, localUrl, localSyncMode, isInitialized]);
|
||||
}, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, isInitialized]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
@@ -71,6 +77,8 @@ export function SpoolmanSettings() {
|
||||
spoolman_enabled: localEnabled ? 'true' : 'false',
|
||||
spoolman_url: localUrl,
|
||||
spoolman_sync_mode: localSyncMode,
|
||||
spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false',
|
||||
spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
|
||||
@@ -246,6 +254,53 @@ export function SpoolmanSettings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
|
||||
{localSyncMode === 'auto' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Disable AMS Estimated Weight Sync</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localDisableWeightSync}
|
||||
onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
|
||||
disabled={!localEnabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
|
||||
{localDisableWeightSync && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Report Partial Usage for Failed Prints</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
When a print fails or is cancelled, report the estimated filament used
|
||||
up to that point based on layer progress.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localReportPartialUsage}
|
||||
onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
|
||||
disabled={!localEnabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status */}
|
||||
{localEnabled && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
|
||||
Reference in New Issue
Block a user