|
@@ -182,6 +182,52 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
|
|
|
return lookup
|
|
return lookup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
|
|
|
|
|
+ """Capture per-slot ``remain%`` + ``tray_uuid`` at print start so the
|
|
|
|
|
+ completion path can compute a remain-delta when 3MF data doesn't cover
|
|
|
|
|
+ the slot (or there's no 3MF at all — #1820).
|
|
|
|
|
+
|
|
|
|
|
+ Returns ``{"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}}``.
|
|
|
|
|
+ Only slots whose ``remain`` is a valid 0..100 int are included; invalid
|
|
|
|
|
+ values mean the AMS hasn't read the spool yet and a delta would be
|
|
|
|
|
+ meaningless. Mirrors the gate in
|
|
|
|
|
+ ``usage_tracker.on_print_start:309``.
|
|
|
|
|
+ """
|
|
|
|
|
+ snapshot: dict[str, dict] = {}
|
|
|
|
|
+ ams_raw = raw_data.get("ams", [])
|
|
|
|
|
+ ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
|
|
|
|
|
+ for ams_unit in ams_data:
|
|
|
|
|
+ if not isinstance(ams_unit, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ ams_id = int(ams_unit.get("id", 0))
|
|
|
|
|
+ for tray in ams_unit.get("tray", []):
|
|
|
|
|
+ if not isinstance(tray, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ tray_id = int(tray.get("id", 0))
|
|
|
|
|
+ remain = tray.get("remain", -1)
|
|
|
|
|
+ if isinstance(remain, int) and 0 <= remain <= 100:
|
|
|
|
|
+ snapshot[f"{ams_id}-{tray_id}"] = {
|
|
|
|
|
+ "remain": remain,
|
|
|
|
|
+ "tray_uuid": tray.get("tray_uuid", "") or "",
|
|
|
|
|
+ }
|
|
|
|
|
+ vt_tray_raw = raw_data.get("vt_tray") or []
|
|
|
|
|
+ if isinstance(vt_tray_raw, dict):
|
|
|
|
|
+ vt_tray_raw = [vt_tray_raw]
|
|
|
|
|
+ for vt in vt_tray_raw:
|
|
|
|
|
+ if not isinstance(vt, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ vt_id = int(vt.get("id", 254))
|
|
|
|
|
+ # 254 → (255, 0), 255 → (255, 1) — matches usage_tracker's encoding.
|
|
|
|
|
+ vt_tray_id = vt_id - 254
|
|
|
|
|
+ remain = vt.get("remain", -1)
|
|
|
|
|
+ if isinstance(remain, int) and 0 <= remain <= 100:
|
|
|
|
|
+ snapshot[f"255-{vt_tray_id}"] = {
|
|
|
|
|
+ "remain": remain,
|
|
|
|
|
+ "tray_uuid": vt.get("tray_uuid", "") or "",
|
|
|
|
|
+ }
|
|
|
|
|
+ return snapshot
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def store_print_data(
|
|
async def store_print_data(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
|
archive_id: int,
|
|
archive_id: int,
|
|
@@ -219,38 +265,65 @@ async def store_print_data(
|
|
|
if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
|
if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- # Get 3MF file path
|
|
|
|
|
|
|
+ # Get current AMS tray state up front — needed both for the 3MF path's
|
|
|
|
|
+ # ams_trays field and for the remain%-delta snapshot (#1820 fallback for
|
|
|
|
|
+ # no-3MF "Untitled" prints, mirroring usage_tracker.on_print_start).
|
|
|
|
|
+ state = printer_manager.get_status(printer_id)
|
|
|
|
|
+ ams_trays: dict[int, dict] = {}
|
|
|
|
|
+ tray_remain_start: dict[str, dict] = {}
|
|
|
|
|
+ if state and state.raw_data:
|
|
|
|
|
+ ams_trays = build_ams_tray_lookup(state.raw_data)
|
|
|
|
|
+ tray_remain_start = _snapshot_tray_remain(state.raw_data)
|
|
|
|
|
+
|
|
|
|
|
+ # Try to read per-slot filament estimates from the 3MF. Two paths can
|
|
|
|
|
+ # leave ``filament_usage`` empty: (1) fallback archive (no .gcode.3mf
|
|
|
|
|
+ # was downloadable from the printer — "Untitled" prints, see #1820),
|
|
|
|
|
+ # (2) 3MF present but slice_info missing per-filament estimates.
|
|
|
|
|
+ # Both fall through to the remain%-delta path at completion.
|
|
|
|
|
+ filament_usage: list | None = None
|
|
|
|
|
+ layer_usage_json: dict | None = None
|
|
|
|
|
+ filament_properties: dict | None = None
|
|
|
full_path = (
|
|
full_path = (
|
|
|
app_settings.base_dir / file_path
|
|
app_settings.base_dir / file_path
|
|
|
) # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
|
|
) # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
|
|
|
- if not full_path.exists():
|
|
|
|
|
- logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
|
|
|
|
|
- return
|
|
|
|
|
-
|
|
|
|
|
- # Resolve the queue item once — used both for the plate-scoped 3MF parsing
|
|
|
|
|
- # fallback (#1697: multi-plate file dispatched for one plate must only count
|
|
|
|
|
- # that plate's filament) and for the ams_mapping fallback below.
|
|
|
|
|
- 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()
|
|
|
|
|
- # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
|
|
|
|
|
- # item's plate_id (queue dispatch path).
|
|
|
|
|
- effective_plate_id = plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
|
|
|
|
|
-
|
|
|
|
|
- # Extract per-filament usage from 3MF (total usage for the dispatched plate,
|
|
|
|
|
- # or the whole file for direct/library prints with no plate_id).
|
|
|
|
|
- filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id)
|
|
|
|
|
- if not filament_usage:
|
|
|
|
|
- logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
|
|
|
|
|
|
|
+ threemf_available = bool(file_path) and full_path.exists()
|
|
|
|
|
+ queue_item = None
|
|
|
|
|
+ if threemf_available:
|
|
|
|
|
+ # Resolve the queue item once — used both for the plate-scoped 3MF parsing
|
|
|
|
|
+ # fallback (#1697: multi-plate file dispatched for one plate must only count
|
|
|
|
|
+ # that plate's filament) and for the ams_mapping fallback below.
|
|
|
|
|
+ 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()
|
|
|
|
|
+ # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
|
|
|
|
|
+ # item's plate_id (queue dispatch path).
|
|
|
|
|
+ effective_plate_id = (
|
|
|
|
|
+ plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
|
|
|
|
|
+ )
|
|
|
|
|
+ filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None
|
|
|
|
|
+
|
|
|
|
|
+ layer_usage = extract_layer_filament_usage_from_3mf(full_path)
|
|
|
|
|
+ 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("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
|
|
|
|
|
+
|
|
|
|
|
+ filament_properties = extract_filament_properties_from_3mf(full_path)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # No 3MF on disk — common for "Untitled" prints whose .gcode.3mf
|
|
|
|
|
+ # was never on the printer's FTP. Logged at debug since the
|
|
|
|
|
+ # fallback path below picks up the slack when remain% is available.
|
|
|
|
|
+ logger.debug("[SPOOLMAN] 3MF file not available: %s", full_path)
|
|
|
|
|
+
|
|
|
|
|
+ # If neither path has anything useful, there's nothing to track.
|
|
|
|
|
+ if not filament_usage and not tray_remain_start:
|
|
|
|
|
+ if threemf_available:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
|
|
|
return
|
|
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)
|
|
|
|
|
-
|
|
|
|
|
# Prefer the explicit mapping captured from the print command, then fall back
|
|
# Prefer the explicit mapping captured from the print command, then fall back
|
|
|
# to any queue mapping stored for scheduled/reprint jobs.
|
|
# to any queue mapping stored for scheduled/reprint jobs.
|
|
|
slot_to_tray = ams_mapping if ams_mapping is not None else None
|
|
slot_to_tray = ams_mapping if ams_mapping is not None else None
|
|
@@ -260,17 +333,6 @@ async def store_print_data(
|
|
|
except json.JSONDecodeError:
|
|
except json.JSONDecodeError:
|
|
|
pass # Ignore malformed AMS mapping; fall back to default slot assignment
|
|
pass # Ignore malformed AMS mapping; fall back to default slot assignment
|
|
|
|
|
|
|
|
- # 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("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
|
|
|
|
|
-
|
|
|
|
|
- # 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)
|
|
# Delete any existing row for this printer/archive (shouldn't exist, but just in case)
|
|
|
await db.execute(
|
|
await db.execute(
|
|
|
delete(ActivePrintSpoolman)
|
|
delete(ActivePrintSpoolman)
|
|
@@ -278,7 +340,8 @@ async def store_print_data(
|
|
|
.where(ActivePrintSpoolman.archive_id == archive_id)
|
|
.where(ActivePrintSpoolman.archive_id == archive_id)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- # Insert new tracking data
|
|
|
|
|
|
|
+ # Insert new tracking data. ``filament_usage`` may be None for the
|
|
|
|
|
+ # no-3MF case; report_usage falls back to ``tray_remain_start``.
|
|
|
tracking = ActivePrintSpoolman(
|
|
tracking = ActivePrintSpoolman(
|
|
|
printer_id=printer_id,
|
|
printer_id=printer_id,
|
|
|
archive_id=archive_id,
|
|
archive_id=archive_id,
|
|
@@ -287,11 +350,18 @@ async def store_print_data(
|
|
|
slot_to_tray=slot_to_tray,
|
|
slot_to_tray=slot_to_tray,
|
|
|
layer_usage=layer_usage_json,
|
|
layer_usage=layer_usage_json,
|
|
|
filament_properties=filament_properties,
|
|
filament_properties=filament_properties,
|
|
|
|
|
+ tray_remain_start=tray_remain_start or None,
|
|
|
)
|
|
)
|
|
|
db.add(tracking)
|
|
db.add(tracking)
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
|
|
|
- logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
|
|
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s (3mf=%s, remain_snapshot=%d slot(s))",
|
|
|
|
|
+ printer_id,
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ "yes" if filament_usage else "no",
|
|
|
|
|
+ len(tray_remain_start),
|
|
|
|
|
+ )
|
|
|
logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
|
|
logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
|
|
|
logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
|
|
logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
|
|
|
if slot_to_tray:
|
|
if slot_to_tray:
|
|
@@ -571,6 +641,7 @@ async def _report_partial_usage(
|
|
|
filament_usage = tracking.filament_usage or []
|
|
filament_usage = tracking.filament_usage or []
|
|
|
ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
|
|
ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
|
|
|
slot_to_tray = tracking.slot_to_tray
|
|
slot_to_tray = tracking.slot_to_tray
|
|
|
|
|
+ tray_remain_start = tracking.tray_remain_start or {}
|
|
|
printer_serial = await _get_printer_serial(printer_id)
|
|
printer_serial = await _get_printer_serial(printer_id)
|
|
|
|
|
|
|
|
client = await _get_spoolman_client_with_fallback()
|
|
client = await _get_spoolman_client_with_fallback()
|
|
@@ -578,6 +649,24 @@ async def _report_partial_usage(
|
|
|
logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
|
|
logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
|
|
+ # No-3MF aborted print (#1820 mirror of the completion path): nothing in
|
|
|
|
|
+ # filament_usage or layer_usage to base partial estimates on, but the
|
|
|
|
|
+ # remain%-delta snapshot we captured at start still describes consumption
|
|
|
|
|
+ # up to the abort moment. Write it the same way report_usage's fallback
|
|
|
|
|
+ # does, then return — there's no 3MF-derived partial to layer on top.
|
|
|
|
|
+ # ``state`` was already fetched at the top of the function for current_layer.
|
|
|
|
|
+ if not filament_usage and not layer_usage and tray_remain_start:
|
|
|
|
|
+ current_lookup = _snapshot_tray_remain(state.raw_data) if state and state.raw_data else {}
|
|
|
|
|
+ await _report_remain_delta_for_slots(
|
|
|
|
|
+ client,
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ tray_remain_start=tray_remain_start,
|
|
|
|
|
+ current_lookup=current_lookup,
|
|
|
|
|
+ handled_global_tray_ids=set(),
|
|
|
|
|
+ archive_id=getattr(tracking, "archive_id", -1),
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
# Try to use accurate G-code parsed data
|
|
# Try to use accurate G-code parsed data
|
|
|
if layer_usage:
|
|
if layer_usage:
|
|
|
layer_usage_int = {
|
|
layer_usage_int = {
|
|
@@ -670,8 +759,15 @@ async def _report_partial_usage(
|
|
|
async def report_usage(printer_id: int, archive_id: int):
|
|
async def report_usage(printer_id: int, archive_id: int):
|
|
|
"""Report filament usage to Spoolman after print completion.
|
|
"""Report filament usage to Spoolman after print completion.
|
|
|
|
|
|
|
|
- Uses per-filament usage data captured at print start to report
|
|
|
|
|
- usage to the correct spools.
|
|
|
|
|
|
|
+ Two writers, mirroring the internal-inventory split in usage_tracker:
|
|
|
|
|
+
|
|
|
|
|
+ 1. **3MF path (primary)** — per-filament slice estimates captured at
|
|
|
|
|
+ print start drive a precise per-slot ``use_spool`` call.
|
|
|
|
|
+ 2. **AMS remain%-delta (fallback)** — for slots the 3MF path didn't
|
|
|
|
|
+ handle (including the no-3MF "Untitled" case from #1820): compute
|
|
|
|
|
+ ``start_remain - current_remain``, multiply by the resolved
|
|
|
|
|
+ Spoolman filament's reference weight, and write the delta. Mirrors
|
|
|
|
|
+ ``usage_tracker.on_print_complete`` Path 2 (line 517).
|
|
|
"""
|
|
"""
|
|
|
async with async_session() as db:
|
|
async with async_session() as db:
|
|
|
from backend.app.api.routes.settings import get_setting
|
|
from backend.app.api.routes.settings import get_setting
|
|
@@ -692,14 +788,15 @@ async def report_usage(printer_id: int, archive_id: int):
|
|
|
filament_usage = tracking.filament_usage or []
|
|
filament_usage = tracking.filament_usage or []
|
|
|
ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
|
|
ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
|
|
|
slot_to_tray = tracking.slot_to_tray
|
|
slot_to_tray = tracking.slot_to_tray
|
|
|
|
|
+ tray_remain_start = tracking.tray_remain_start or {}
|
|
|
printer_serial = await _get_printer_serial(printer_id)
|
|
printer_serial = await _get_printer_serial(printer_id)
|
|
|
|
|
|
|
|
# Delete tracking row (we're done with it)
|
|
# Delete tracking row (we're done with it)
|
|
|
await db.delete(tracking)
|
|
await db.delete(tracking)
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
|
|
|
- if not filament_usage:
|
|
|
|
|
- logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
|
|
|
|
|
|
|
+ if not filament_usage and not tray_remain_start:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] No usage data or remain-snapshot for archive %s", archive_id)
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
# Check if Spoolman is enabled
|
|
# Check if Spoolman is enabled
|
|
@@ -712,20 +809,48 @@ async def report_usage(printer_id: int, archive_id: int):
|
|
|
logger.warning("[SPOOLMAN] Not reachable for usage reporting")
|
|
logger.warning("[SPOOLMAN] Not reachable for usage reporting")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
|
|
|
|
|
-
|
|
|
|
|
- usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
|
|
|
|
|
slot_colors: dict[int, str] = {}
|
|
slot_colors: dict[int, str] = {}
|
|
|
- spools_updated = await _report_spool_usage_for_slots(
|
|
|
|
|
- client,
|
|
|
|
|
- usage_items,
|
|
|
|
|
- ams_trays,
|
|
|
|
|
- slot_to_tray,
|
|
|
|
|
- f"Archive {archive_id}",
|
|
|
|
|
- printer_serial,
|
|
|
|
|
- printer_id=printer_id,
|
|
|
|
|
- slot_colors_out=slot_colors,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ handled_global_tray_ids: set[int] = set()
|
|
|
|
|
+ spools_updated = 0
|
|
|
|
|
+
|
|
|
|
|
+ # --- Path 1: 3MF per-slot estimates -----------------------------
|
|
|
|
|
+ if filament_usage:
|
|
|
|
|
+ logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
|
|
|
|
|
+ usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
|
|
|
|
|
+ spools_updated = await _report_spool_usage_for_slots(
|
|
|
|
|
+ client,
|
|
|
|
|
+ usage_items,
|
|
|
|
|
+ ams_trays,
|
|
|
|
|
+ slot_to_tray,
|
|
|
|
|
+ f"Archive {archive_id}",
|
|
|
|
|
+ printer_serial,
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ slot_colors_out=slot_colors,
|
|
|
|
|
+ )
|
|
|
|
|
+ # Track which physical slots the 3MF path already covered so
|
|
|
|
|
+ # Path 2 doesn't double-charge them.
|
|
|
|
|
+ for u in filament_usage:
|
|
|
|
|
+ slot_id = u.get("slot_id", 0)
|
|
|
|
|
+ handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
|
|
|
|
|
+
|
|
|
|
|
+ # --- Path 2: AMS remain%-delta for slots 3MF didn't cover -------
|
|
|
|
|
+ # Triggered for no-3MF "Untitled" prints (#1820) AND for partial
|
|
|
|
|
+ # 3MF coverage (slots whose filament_id wasn't in slice_info).
|
|
|
|
|
+ if tray_remain_start:
|
|
|
|
|
+ from backend.app.services.printer_manager import printer_manager
|
|
|
|
|
+
|
|
|
|
|
+ current = printer_manager.get_status(printer_id)
|
|
|
|
|
+ current_lookup = _snapshot_tray_remain(current.raw_data) if current and current.raw_data else {}
|
|
|
|
|
+ fallback_updates = await _report_remain_delta_for_slots(
|
|
|
|
|
+ client,
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ tray_remain_start=tray_remain_start,
|
|
|
|
|
+ current_lookup=current_lookup,
|
|
|
|
|
+ handled_global_tray_ids=handled_global_tray_ids,
|
|
|
|
|
+ archive_id=archive_id,
|
|
|
|
|
+ slot_colors_out=slot_colors,
|
|
|
|
|
+ )
|
|
|
|
|
+ spools_updated += fallback_updates
|
|
|
|
|
|
|
|
if spools_updated == 0:
|
|
if spools_updated == 0:
|
|
|
logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
|
|
logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
|
|
@@ -738,6 +863,121 @@ async def report_usage(printer_id: int, archive_id: int):
|
|
|
await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
|
|
await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _report_remain_delta_for_slots(
|
|
|
|
|
+ client,
|
|
|
|
|
+ *,
|
|
|
|
|
+ printer_id: int,
|
|
|
|
|
+ tray_remain_start: dict[str, dict],
|
|
|
|
|
+ current_lookup: dict[str, dict],
|
|
|
|
|
+ handled_global_tray_ids: set[int],
|
|
|
|
|
+ archive_id: int,
|
|
|
|
|
+ slot_colors_out: dict[int, str] | None = None,
|
|
|
|
|
+) -> int:
|
|
|
|
|
+ """AMS remain%-delta path: write ``(start - current) * filament.weight``
|
|
|
|
|
+ grams to Spoolman for slots the 3MF path didn't cover.
|
|
|
|
|
+
|
|
|
|
|
+ Mirrors ``usage_tracker.on_print_complete`` Path 2: per-slot, gated on a
|
|
|
|
|
+ valid current ``remain%``, skipped on spool swap (``tray_uuid`` changed),
|
|
|
|
|
+ using the resolved spool's filament reference weight rather than MQTT's
|
|
|
|
|
+ unreliable ``tray_weight`` (which is the failure mode #1119 documented).
|
|
|
|
|
+ """
|
|
|
|
|
+ spools_updated = 0
|
|
|
|
|
+ for slot_key, start in tray_remain_start.items():
|
|
|
|
|
+ try:
|
|
|
|
|
+ ams_id_str, tray_id_str = slot_key.split("-", 1)
|
|
|
|
|
+ ams_id, tray_id = int(ams_id_str), int(tray_id_str)
|
|
|
|
|
+ except (ValueError, AttributeError):
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Skip slots already handled by the 3MF path. Encoding mirrors
|
|
|
|
|
+ # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps
|
|
|
|
|
+ # its native id (>=128), regular AMS slots are ams_id*4+tray_id.
|
|
|
|
|
+ if ams_id == 255:
|
|
|
|
|
+ global_tray_id = 254 + tray_id
|
|
|
|
|
+ elif ams_id >= 128:
|
|
|
|
|
+ global_tray_id = ams_id
|
|
|
|
|
+ else:
|
|
|
|
|
+ global_tray_id = ams_id * 4 + tray_id
|
|
|
|
|
+ if global_tray_id in handled_global_tray_ids:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ current = current_lookup.get(slot_key)
|
|
|
|
|
+ if not current:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] AMS%d-T%d: no current remain%% at completion, skipping fallback", ams_id, tray_id)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Spool swap mid-print — tray_uuid changed. We don't know how much
|
|
|
|
|
+ # of the print went to which spool; skip rather than mis-attribute.
|
|
|
|
|
+ start_uuid = (start.get("tray_uuid") or "").lower()
|
|
|
|
|
+ cur_uuid = (current.get("tray_uuid") or "").lower()
|
|
|
|
|
+ if start_uuid and cur_uuid and start_uuid != cur_uuid:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[SPOOLMAN] AMS%d-T%d: spool swapped mid-print (uuid changed), skipping remain-delta", ams_id, tray_id
|
|
|
|
|
+ )
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ delta_pct = start["remain"] - current["remain"]
|
|
|
|
|
+ if delta_pct <= 0:
|
|
|
|
|
+ continue # No consumption captured at AMS granularity, or refilled
|
|
|
|
|
+
|
|
|
|
|
+ spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
|
|
|
|
|
+ if spool_id is None:
|
|
|
|
|
+ logger.debug("[SPOOLMAN] AMS%d-T%d: no Spoolman slot assignment, skipping fallback", ams_id, tray_id)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Look up the spool's filament reference weight. Use a fresh GET so
|
|
|
|
|
+ # we don't depend on a stale cached_spools list. Failure here is
|
|
|
|
|
+ # silent-skip rather than fatal — other slots can still be written.
|
|
|
|
|
+ try:
|
|
|
|
|
+ spool = await client.get_spool(spool_id)
|
|
|
|
|
+ except Exception as exc: # noqa: BLE001
|
|
|
|
|
+ logger.debug("[SPOOLMAN] AMS%d-T%d: get_spool(%s) failed: %s", ams_id, tray_id, spool_id, exc)
|
|
|
|
|
+ continue
|
|
|
|
|
+ filament = spool.get("filament") or {}
|
|
|
|
|
+ ref_weight = filament.get("weight")
|
|
|
|
|
+ if not ref_weight or ref_weight <= 0:
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "[SPOOLMAN] AMS%d-T%d: spool %s has no filament.weight, skipping remain-delta",
|
|
|
|
|
+ ams_id,
|
|
|
|
|
+ tray_id,
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ grams_used = round((delta_pct / 100.0) * ref_weight, 2)
|
|
|
|
|
+ if grams_used <= 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ await client.use_spool(spool_id, grams_used)
|
|
|
|
|
+ except Exception as exc: # noqa: BLE001
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "[SPOOLMAN] AMS%d-T%d: use_spool(%s, %.2fg) failed: %s", ams_id, tray_id, spool_id, grams_used, exc
|
|
|
|
|
+ )
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ spools_updated += 1
|
|
|
|
|
+ if slot_colors_out is not None:
|
|
|
|
|
+ color = filament.get("color_hex")
|
|
|
|
|
+ if color:
|
|
|
|
|
+ # No 3MF slot_id for this path — use the AMS slot key so the
|
|
|
|
|
+ # colour map can still be inspected by callers if needed.
|
|
|
|
|
+ # The archive-colour rewrite (#1494) keys on 3MF slot_ids so
|
|
|
|
|
+ # remain-delta-only prints intentionally don't participate
|
|
|
|
|
+ # in that rewrite (matches usage_tracker's slot_id=None).
|
|
|
|
|
+ slot_colors_out[-(global_tray_id + 1)] = color
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s",
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ ams_id,
|
|
|
|
|
+ tray_id,
|
|
|
|
|
+ grams_used,
|
|
|
|
|
+ delta_pct,
|
|
|
|
|
+ ref_weight,
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return spools_updated
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def _apply_spool_colors_to_archive(
|
|
async def _apply_spool_colors_to_archive(
|
|
|
db,
|
|
db,
|
|
|
archive_id: int,
|
|
archive_id: int,
|