mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
usage_tracker's tray-switch split has never had a Spoolman peer. An AMS same-material runout switch mid-print charged the whole slot to the origin spool via the (via tag) path and double-credited the backup via remain-delta — origin exceeded initial_weight. Extract the segment-math into utils/tray_split.compute_tray_split_grams and call it from both writers so the two inventory backends attribute mid-print switches identically. spoolman_tracking gains _report_spool_usage_split_by_tray_changes; the Path 2 remain-delta fallback now skips trays the split path covered, killing the double-count.
1233 lines
52 KiB
Python
1233 lines
52 KiB
Python
"""Spoolman per-filament usage tracking for active prints.
|
|
|
|
Captures AMS tray state and G-code data at print start, then reports
|
|
per-filament usage to the correct Spoolman spools at print completion.
|
|
Supports accurate partial usage reporting for failed/cancelled prints.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
|
|
from sqlalchemy import delete, select
|
|
|
|
from backend.app.core.config import settings as app_settings
|
|
from backend.app.core.database import async_session
|
|
from backend.app.services.spoolman import (
|
|
SpoolmanClientError,
|
|
SpoolmanNotFoundError,
|
|
SpoolmanUnavailableError,
|
|
get_spoolman_client,
|
|
init_spoolman_client,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Zero UUID used by Bambu printers for empty/unset tray_uuid
|
|
_ZERO_UUID = "00000000000000000000000000000000"
|
|
_ZERO_TAG_UID = "0000000000000000"
|
|
|
|
|
|
def _is_non_zero_identifier(value: str) -> bool:
|
|
"""Return True when identifier is non-empty and not all zeros."""
|
|
if not value:
|
|
return False
|
|
return set(value) != {"0"}
|
|
|
|
|
|
def _to_fixed_hex(value: int, width: int) -> str:
|
|
"""Mirror frontend toFixedHex(): uppercase, zero-padded, fixed width."""
|
|
safe = max(0, int(value))
|
|
return format(safe, "X").zfill(width)[-width:]
|
|
|
|
|
|
def _hash_serial_to_hex32(serial: str) -> str:
|
|
"""Mirror frontend hashSerialToHex32() exactly (32-bit FNV-1a)."""
|
|
input_str = (serial or "").strip().upper()
|
|
hash_value = 0x811C9DC5
|
|
for char in input_str:
|
|
hash_value ^= ord(char)
|
|
hash_value = (hash_value * 0x01000193) & 0xFFFFFFFF
|
|
return format(hash_value, "X").zfill(8)
|
|
|
|
|
|
def _global_tray_id_to_ams_slot(global_tray_id: int) -> tuple[int, int]:
|
|
"""Convert global tray id to (ams_id, tray_id) tuple for fallback tag generation."""
|
|
# External spool slots use IDs 254/255 and map to ams_id=255 tray_id=0/1.
|
|
if global_tray_id >= 254:
|
|
return 255, max(0, global_tray_id - 254)
|
|
# AMS-HT units are addressed by ams_id directly and have a single tray.
|
|
if global_tray_id >= 128:
|
|
return global_tray_id, 0
|
|
# Standard AMS units: four trays each.
|
|
return global_tray_id // 4, global_tray_id % 4
|
|
|
|
|
|
def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:
|
|
"""Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly."""
|
|
if not printer_serial:
|
|
return ""
|
|
ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
|
|
return get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
|
|
|
|
|
|
def get_fallback_spool_tag_for_slot(printer_serial: str, ams_id: int, tray_id: int) -> str:
|
|
"""Public helper matching frontend getFallbackSpoolTag(serial, amsId, trayId).
|
|
|
|
Used by stale-tag cleanup (#1457) to detect Spoolman spools still holding
|
|
this slot's deterministic fallback tag in extra.tag.
|
|
"""
|
|
if not printer_serial:
|
|
return ""
|
|
return f"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}"
|
|
|
|
|
|
def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None) -> str:
|
|
"""Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
|
|
|
|
Returns empty string if no usable identifier is found.
|
|
"""
|
|
tray_uuid = str(tray_info.get("tray_uuid", "") or "")
|
|
tag_uid = str(tray_info.get("tag_uid", "") or "")
|
|
|
|
if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
|
|
return tray_uuid
|
|
if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
|
|
return tag_uid
|
|
if global_tray_id is not None:
|
|
return _get_fallback_spool_tag(printer_serial, global_tray_id)
|
|
return ""
|
|
|
|
|
|
async def _get_printer_serial(printer_id: int) -> str:
|
|
"""Get printer serial for deterministic fallback tag generation."""
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
printer_info = printer_manager.get_printer(printer_id)
|
|
if printer_info and printer_info.serial_number:
|
|
return printer_info.serial_number
|
|
|
|
async with async_session() as db:
|
|
result = await db.execute(select(Printer.serial_number).where(Printer.id == printer_id))
|
|
serial_number = result.scalar_one_or_none()
|
|
return serial_number or ""
|
|
|
|
|
|
def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays: dict | None = None) -> int:
|
|
"""Map a 1-based slot_id to a global_tray_id using optional custom mapping.
|
|
|
|
Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
|
|
A value of -1 in the custom mapping means the slicer routed this slot to
|
|
the external spool. BambuStudio converts virtual tray IDs (254/255) to -1
|
|
in the flat ams_mapping array before sending to the printer — see
|
|
start_print() in bambu_mqtt.py which documents this convention. We mirror
|
|
it here: when -1 is seen, look up the external spool's actual
|
|
global_tray_id (254/255) in ams_trays rather than falling through to the
|
|
position-based default (which would map slot_id=1 to the first AMS tray
|
|
and credit an unrelated spool — see #1276, regression of #853).
|
|
Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
|
|
naturally follow standard AMS trays, matching the slicer's slot numbering.
|
|
Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
|
|
"""
|
|
if slot_to_tray and slot_id <= len(slot_to_tray):
|
|
mapped_tray = slot_to_tray[slot_id - 1]
|
|
if mapped_tray >= 0:
|
|
return mapped_tray
|
|
if mapped_tray == -1 and ams_trays:
|
|
# -1 means external spool. 254 = VIRTUAL_TRAY_DEPUTY_ID (main on
|
|
# single-nozzle, left/deputy on H2D dual-nozzle); 255 =
|
|
# VIRTUAL_TRAY_MAIN_ID. Prefer 254 when both exist since that's
|
|
# what single-nozzle printers report via tray_now.
|
|
for ext_id in (254, 255):
|
|
if ext_id in ams_trays:
|
|
return ext_id
|
|
# Position-based default: sort available tray IDs so external spools (254/255)
|
|
# come after standard AMS trays, matching the slicer's slot assignment order.
|
|
if ams_trays:
|
|
sorted_tray_ids = sorted(ams_trays.keys())
|
|
if slot_id <= len(sorted_tray_ids):
|
|
return sorted_tray_ids[slot_id - 1]
|
|
return slot_id - 1
|
|
|
|
|
|
def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
|
|
"""Build lookup of global_tray_id -> tray info from printer state.
|
|
|
|
Returns: {0: {"tray_uuid": "...", "tag_uid": "...", "tray_type": "..."}, ...}
|
|
"""
|
|
lookup = {}
|
|
ams_data = raw_data.get("ams", [])
|
|
for ams_unit in ams_data:
|
|
ams_id = int(ams_unit.get("id", 0))
|
|
for tray in ams_unit.get("tray", []):
|
|
tray_id = int(tray.get("id", 0))
|
|
# AMS-HT units have IDs starting at 128 with a single tray
|
|
global_tray_id = ams_id if ams_id >= 128 else 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(s) (vt_tray is a list, global_tray_id from each entry's "id")
|
|
for vt in raw_data.get("vt_tray") or []:
|
|
if vt.get("tray_type"):
|
|
tray_id = int(vt.get("id", 254))
|
|
lookup[tray_id] = {
|
|
"tray_uuid": vt.get("tray_uuid", ""),
|
|
"tag_uid": vt.get("tag_uid", ""),
|
|
"tray_type": vt.get("tray_type", ""),
|
|
}
|
|
|
|
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(
|
|
printer_id: int,
|
|
archive_id: int,
|
|
file_path: str,
|
|
db,
|
|
printer_manager,
|
|
ams_mapping: list[int] | None = None,
|
|
plate_id: int | None = None,
|
|
):
|
|
"""Store Spoolman tracking data at print start (persisted to database).
|
|
|
|
Per-print tracking is the primary weight-update path for Spoolman, mirroring
|
|
how the internal Filament Inventory works. The legacy AMS-remain%-based sync
|
|
is no longer used as a weight writer (#1119), so this runs whenever Spoolman
|
|
is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
|
|
|
|
``plate_id``, when set, scopes the 3MF filament extract to a single plate so
|
|
queue / direct-Print dispatch of plate N of a multi-plate file doesn't
|
|
attribute every plate's filament to the printed spool (#1697). When unset,
|
|
the queue item's plate_id (if any) is used; otherwise the whole-file sum is
|
|
extracted, which is correct for direct prints that target the first/only
|
|
plate of a single-plate file.
|
|
"""
|
|
from backend.app.api.routes.settings import get_setting
|
|
from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
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
|
|
|
|
# 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 = (
|
|
app_settings.base_dir / file_path
|
|
) # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
|
|
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
|
|
|
|
# Prefer the explicit mapping captured from the print command, then fall back
|
|
# to any queue mapping stored for scheduled/reprint jobs.
|
|
slot_to_tray = ams_mapping if ams_mapping is not None else None
|
|
if not slot_to_tray and queue_item and queue_item.ams_mapping:
|
|
try:
|
|
slot_to_tray = json.loads(queue_item.ams_mapping)
|
|
except json.JSONDecodeError:
|
|
pass # Ignore malformed AMS mapping; fall back to default slot assignment
|
|
|
|
# Delete any existing row for this printer/archive (shouldn't exist, but just in case)
|
|
await db.execute(
|
|
delete(ActivePrintSpoolman)
|
|
.where(ActivePrintSpoolman.printer_id == printer_id)
|
|
.where(ActivePrintSpoolman.archive_id == archive_id)
|
|
)
|
|
|
|
# Insert new tracking data. ``filament_usage`` may be None for the
|
|
# no-3MF case; report_usage falls back to ``tray_remain_start``.
|
|
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,
|
|
tray_remain_start=tray_remain_start or None,
|
|
)
|
|
db.add(tracking)
|
|
await db.commit()
|
|
|
|
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] AMS trays: %s", list(ams_trays.keys()))
|
|
if slot_to_tray:
|
|
logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
|
|
if layer_usage_json:
|
|
logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
|
|
|
|
|
|
async def cleanup_tracking(
|
|
printer_id: int,
|
|
archive_id: int,
|
|
db,
|
|
last_layer_num: int | None = None,
|
|
last_progress: int | None = None,
|
|
):
|
|
"""Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
|
|
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("[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s", printer_id, archive_id)
|
|
return
|
|
|
|
# Try to report partial usage before cleanup
|
|
try:
|
|
await _report_partial_usage(
|
|
printer_id,
|
|
tracking,
|
|
last_layer_num=last_layer_num,
|
|
last_progress=last_progress,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("[SPOOLMAN] Partial usage report failed: %s", 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("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
|
|
|
|
|
|
async def _get_spoolman_client_with_fallback():
|
|
"""Get Spoolman client, initializing from settings if needed.
|
|
|
|
Returns (client, is_healthy) tuple. Client may be None.
|
|
"""
|
|
client = await get_spoolman_client()
|
|
if not client:
|
|
async with async_session() as db:
|
|
from backend.app.api.routes.settings import get_setting
|
|
|
|
spoolman_url = await get_setting(db, "spoolman_url")
|
|
if spoolman_url:
|
|
try:
|
|
client = await init_spoolman_client(spoolman_url)
|
|
except ValueError as exc:
|
|
logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
|
|
return None
|
|
|
|
if not client:
|
|
return None
|
|
if not await client.health_check():
|
|
logger.warning("Spoolman health check failed; skipping usage reporting")
|
|
return None
|
|
|
|
return client
|
|
|
|
|
|
async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tray_id: int) -> int | None:
|
|
"""Look up the Spoolman spool ID locally bound to (printer, ams, tray).
|
|
|
|
Fallback path for #1459: when a tag-less spool was assigned via the
|
|
Bambuddy UI, the user's deterministic fallback tag is intentionally NOT
|
|
written to Spoolman's extra.tag (kept clean per #1457), so
|
|
find_spool_by_tag misses. The local spoolman_slot_assignments table is
|
|
the authoritative binding for those spools.
|
|
"""
|
|
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
|
async with async_session() as db:
|
|
result = await db.execute(
|
|
select(SpoolmanSlotAssignment.spoolman_spool_id).where(
|
|
SpoolmanSlotAssignment.printer_id == printer_id,
|
|
SpoolmanSlotAssignment.ams_id == ams_id,
|
|
SpoolmanSlotAssignment.tray_id == tray_id,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def _report_spool_usage_for_slots(
|
|
client,
|
|
filament_usage_items: list[tuple[int, float]],
|
|
ams_trays: dict[int, dict],
|
|
slot_to_tray: list | None,
|
|
method_label: str,
|
|
printer_serial: str = "",
|
|
printer_id: int | None = None,
|
|
slot_colors_out: dict[int, str] | None = None,
|
|
) -> int:
|
|
"""Report usage to Spoolman for a list of (slot_id, grams) pairs.
|
|
|
|
Resolution order per slot: (1) Spoolman extra.tag match against the
|
|
tray's RFID or deterministic fallback tag, (2) #1459 fallback —
|
|
local spoolman_slot_assignments table keyed by (printer_id, ams_id,
|
|
tray_id). Without (2), tag-less spools assigned via the Bambuddy UI
|
|
never get their weight decremented because their extra.tag is empty
|
|
on the Spoolman side.
|
|
|
|
When ``slot_colors_out`` is provided it is populated with
|
|
``{slot_id: color_hex}`` for every resolved spool — used by
|
|
:func:`report_usage` to stamp the archive's filament colour from the
|
|
Spoolman spool rather than the slicer's 3MF value (#1494).
|
|
|
|
Returns number of spools successfully updated.
|
|
"""
|
|
spools_updated = 0
|
|
for slot_id, grams_used in filament_usage_items:
|
|
if grams_used <= 0:
|
|
continue
|
|
|
|
global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
|
|
tray_info = ams_trays.get(global_tray_id)
|
|
if not tray_info:
|
|
logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
|
|
continue
|
|
|
|
is_external = global_tray_id >= 254
|
|
tray_type = tray_info.get("tray_type", "")
|
|
logger.debug(
|
|
"[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
|
|
slot_id,
|
|
global_tray_id,
|
|
tray_type or "unknown",
|
|
is_external,
|
|
)
|
|
|
|
spool_id_to_use: int | None = None
|
|
resolution_path = ""
|
|
# color_hex of the resolved spool's filament, for the #1494 archive
|
|
# colour rewrite. The tag path already has the full spool object;
|
|
# the slot-assignment path only yields an id and is fetched below.
|
|
spool_color_hex: str | None = None
|
|
|
|
spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
|
|
if spool_tag:
|
|
spool = await client.find_spool_by_tag(spool_tag)
|
|
if spool:
|
|
spool_id_to_use = spool["id"]
|
|
resolution_path = "tag"
|
|
spool_color_hex = (spool.get("filament") or {}).get("color_hex")
|
|
|
|
if spool_id_to_use is None and printer_id is not None:
|
|
ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
|
|
spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
|
|
if spool_id_to_use is not None:
|
|
resolution_path = "slot-assignment"
|
|
|
|
if spool_id_to_use is None:
|
|
logger.debug(
|
|
"[SPOOLMAN] Slot %s: no spool resolved (tag=%s, no slot-assignment)",
|
|
slot_id,
|
|
spool_tag[:16] if spool_tag else "none",
|
|
)
|
|
continue
|
|
|
|
# Record the spool's filament colour for the archive rewrite (#1494).
|
|
# The slot-assignment path resolved only an id, so fetch the spool.
|
|
# Strictly best-effort: a colour-fetch failure must never abort the
|
|
# weight reporting for the remaining slots, so the catch is broad.
|
|
if slot_colors_out is not None:
|
|
if spool_color_hex is None:
|
|
try:
|
|
full_spool = await client.get_spool(spool_id_to_use)
|
|
spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
|
|
except Exception as exc: # noqa: BLE001 — colour is non-critical
|
|
logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc)
|
|
if spool_color_hex:
|
|
slot_colors_out[slot_id] = spool_color_hex
|
|
|
|
try:
|
|
await client.use_spool(spool_id_to_use, grams_used)
|
|
logger.info(
|
|
"[SPOOLMAN] %s: slot %s: %sg -> spool %s (via %s)",
|
|
method_label,
|
|
slot_id,
|
|
grams_used,
|
|
spool_id_to_use,
|
|
resolution_path,
|
|
)
|
|
spools_updated += 1
|
|
except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
|
|
logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
|
|
|
|
return spools_updated
|
|
|
|
|
|
async def _report_spool_usage_split_by_tray_changes(
|
|
client,
|
|
filament_usage: list[dict],
|
|
tray_changes: list[tuple[int, int]],
|
|
ams_trays: dict[int, dict],
|
|
layer_usage: dict[int, dict[int, float]] | None,
|
|
filament_properties: dict | None,
|
|
total_layers: int,
|
|
last_layer_num: int,
|
|
method_label: str,
|
|
printer_serial: str,
|
|
printer_id: int,
|
|
slot_colors_out: dict[int, str] | None = None,
|
|
) -> tuple[int, set[int]]:
|
|
"""Split each slot's grams across ``tray_changes`` and charge per-segment.
|
|
|
|
Mirrors ``usage_tracker`` Path 1's tray-switch branch so Spoolman and
|
|
the internal Spool inventory attribute mid-print AMS-backup switches
|
|
identically (#1793 — reporter's origin spool was over-charged the
|
|
whole print because this path didn't exist). ``compute_tray_split_grams``
|
|
holds the shared segment-math; this function wraps the per-segment
|
|
spool resolution + ``use_spool`` sink for the Spoolman side.
|
|
|
|
Returns ``(spools_updated, handled_global_tray_ids)`` — the caller
|
|
passes ``handled_global_tray_ids`` into the remain-delta fallback so
|
|
a tray attributed here is not double-charged there.
|
|
"""
|
|
from backend.app.utils.tray_split import compute_tray_split_grams
|
|
|
|
spools_updated = 0
|
|
handled_global_tray_ids: set[int] = set()
|
|
|
|
for usage in filament_usage:
|
|
slot_id = usage.get("slot_id", 0)
|
|
total_weight = usage.get("used_g", 0)
|
|
if total_weight <= 0 or slot_id <= 0:
|
|
continue
|
|
|
|
props = (filament_properties or {}).get(str(slot_id)) or (filament_properties or {}).get(slot_id) or {}
|
|
segments = compute_tray_split_grams(
|
|
tray_changes=tray_changes,
|
|
total_weight=float(total_weight),
|
|
slot_id=slot_id,
|
|
layer_usage=layer_usage,
|
|
density=float(props.get("density", 1.24)),
|
|
diameter=float(props.get("diameter", 1.75)),
|
|
total_layers=total_layers,
|
|
last_layer_num=last_layer_num,
|
|
)
|
|
|
|
for seg_idx, tray_global, segment_grams in segments:
|
|
if segment_grams <= 0:
|
|
continue
|
|
|
|
# Mark this tray as handled BEFORE the resolution attempt so
|
|
# remain-delta doesn't double-charge it, even if we fail to
|
|
# find a spool below. Matches usage_tracker behaviour: the
|
|
# tray was physically fed from during this print, whether or
|
|
# not Spoolman happens to have a matching row.
|
|
handled_global_tray_ids.add(tray_global)
|
|
|
|
tray_info = ams_trays.get(tray_global) or {}
|
|
spool_id_to_use: int | None = None
|
|
resolution_path = ""
|
|
spool_color_hex: str | None = None
|
|
|
|
spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
|
|
if spool_tag:
|
|
spool = await client.find_spool_by_tag(spool_tag)
|
|
if spool:
|
|
spool_id_to_use = spool["id"]
|
|
resolution_path = "tag"
|
|
spool_color_hex = (spool.get("filament") or {}).get("color_hex")
|
|
|
|
if spool_id_to_use is None:
|
|
seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global)
|
|
spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, seg_ams_id, seg_tray_id)
|
|
if spool_id_to_use is not None:
|
|
resolution_path = "slot-assignment"
|
|
|
|
if spool_id_to_use is None:
|
|
logger.info(
|
|
"[SPOOLMAN] Split slot %s seg %s tray=%d: no spool resolved — %.2fg lost from split accounting",
|
|
slot_id,
|
|
seg_idx,
|
|
tray_global,
|
|
segment_grams,
|
|
)
|
|
continue
|
|
|
|
# Colour rewrite (#1494) — first segment for a slot wins. The
|
|
# UI displays a single colour per slot, so later segments on the
|
|
# same slot don't overwrite (a backup swap can be a different
|
|
# colour but the archive card stays consistent with the origin).
|
|
if slot_colors_out is not None and slot_id not in slot_colors_out:
|
|
if spool_color_hex is None:
|
|
try:
|
|
full_spool = await client.get_spool(spool_id_to_use)
|
|
spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
|
|
except Exception as exc: # noqa: BLE001 — colour is non-critical
|
|
logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool colour: %s", slot_id, exc)
|
|
if spool_color_hex:
|
|
slot_colors_out[slot_id] = spool_color_hex
|
|
|
|
try:
|
|
await client.use_spool(spool_id_to_use, round(segment_grams, 2))
|
|
logger.info(
|
|
"[SPOOLMAN] %s: slot %s seg %s tray=%d: %.2fg -> spool %s (via %s)",
|
|
method_label,
|
|
slot_id,
|
|
seg_idx,
|
|
tray_global,
|
|
segment_grams,
|
|
spool_id_to_use,
|
|
resolution_path,
|
|
)
|
|
spools_updated += 1
|
|
except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
|
|
logger.warning(
|
|
"[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s",
|
|
slot_id,
|
|
seg_idx,
|
|
spool_id_to_use,
|
|
exc,
|
|
)
|
|
|
|
return spools_updated, handled_global_tray_ids
|
|
|
|
|
|
async def _report_partial_usage(
|
|
printer_id: int,
|
|
tracking,
|
|
last_layer_num: int | None = None,
|
|
last_progress: int | None = None,
|
|
):
|
|
"""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.
|
|
"""
|
|
from backend.app.services.printer_manager import printer_manager
|
|
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
|
|
|
|
# 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 current printer state for layer progress.
|
|
# On failed/aborted prints the firmware may already reset to IDLE with layer=0,
|
|
# so we fall back to completion-time hints captured from MQTT.
|
|
state = printer_manager.get_status(printer_id)
|
|
current_layer = state.layer_num if state else None
|
|
total_layers = state.total_layers if state else None
|
|
|
|
if (not current_layer or current_layer <= 0) and last_layer_num and last_layer_num > 0:
|
|
current_layer = last_layer_num
|
|
logger.debug("[SPOOLMAN] Using captured last_layer_num=%s for partial usage", current_layer)
|
|
|
|
progress_ratio_from_event = None
|
|
if last_progress is not None:
|
|
try:
|
|
progress_ratio_from_event = min(max(float(last_progress), 0.0), 100.0) / 100.0
|
|
except (TypeError, ValueError):
|
|
progress_ratio_from_event = None
|
|
|
|
if (not current_layer or current_layer <= 0) and progress_ratio_from_event and total_layers and total_layers > 0:
|
|
current_layer = max(1, int(round(total_layers * progress_ratio_from_event)))
|
|
logger.debug(
|
|
"[SPOOLMAN] Estimated layer from last_progress=%s%% and total_layers=%s -> %s",
|
|
last_progress,
|
|
total_layers,
|
|
current_layer,
|
|
)
|
|
|
|
if not current_layer or current_layer <= 0:
|
|
logger.debug(
|
|
"[SPOOLMAN] No progress to report (layer 0/unknown, last_layer_num=%s, last_progress=%s)",
|
|
last_layer_num,
|
|
last_progress,
|
|
)
|
|
return
|
|
|
|
logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", 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 = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
|
|
slot_to_tray = tracking.slot_to_tray
|
|
tray_remain_start = tracking.tray_remain_start or {}
|
|
printer_serial = await _get_printer_serial(printer_id)
|
|
|
|
client = await _get_spoolman_client_with_fallback()
|
|
if not client:
|
|
logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
|
|
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
|
|
if layer_usage:
|
|
layer_usage_int = {
|
|
int(layer): {int(fid): mm for fid, mm in filaments.items()} for layer, filaments in layer_usage.items()
|
|
}
|
|
usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
|
|
|
|
if usage_mm:
|
|
logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
|
|
|
|
# Build (slot_id, grams) list using Spoolman densities with 3MF fallback
|
|
usage_items = []
|
|
for filament_id, mm_used in usage_mm.items():
|
|
slot_id = filament_id + 1 # filament_id is 0-based, slot_id is 1-based
|
|
|
|
# Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
|
|
global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
|
|
tray_info = ams_trays.get(global_tray_id)
|
|
density = None
|
|
diameter = 1.75
|
|
|
|
if tray_info:
|
|
spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
|
|
if spool_tag:
|
|
spool = await client.find_spool_by_tag(spool_tag)
|
|
if spool:
|
|
filament_data = spool.get("filament", {})
|
|
density = filament_data.get("density")
|
|
diameter = filament_data.get("diameter", 1.75)
|
|
|
|
if not density:
|
|
props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
|
|
density = props.get("density", 1.24)
|
|
logger.debug("[SPOOLMAN] Using fallback density %s for slot %s", density, slot_id)
|
|
|
|
grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
|
|
usage_items.append((slot_id, grams_used))
|
|
|
|
spools_updated = await _report_spool_usage_for_slots(
|
|
client,
|
|
usage_items,
|
|
ams_trays,
|
|
slot_to_tray,
|
|
"Partial (G-code)",
|
|
printer_serial,
|
|
printer_id=printer_id,
|
|
)
|
|
if spools_updated > 0:
|
|
logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
|
|
return
|
|
|
|
# Fallback: linear interpolation (if no G-code data available)
|
|
progress_ratio = None
|
|
if total_layers and total_layers > 0:
|
|
progress_ratio = min(current_layer / total_layers, 1.0)
|
|
elif progress_ratio_from_event is not None:
|
|
progress_ratio = progress_ratio_from_event
|
|
|
|
if progress_ratio is None:
|
|
logger.debug(
|
|
"[SPOOLMAN] Cannot use linear fallback: total_layers=%s, last_progress=%s",
|
|
total_layers,
|
|
last_progress,
|
|
)
|
|
return
|
|
|
|
logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
|
|
|
|
usage_items = []
|
|
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:
|
|
partial_used_g = round(total_used_g * progress_ratio, 2)
|
|
usage_items.append((slot_id, partial_used_g))
|
|
|
|
spools_updated = await _report_spool_usage_for_slots(
|
|
client,
|
|
usage_items,
|
|
ams_trays,
|
|
slot_to_tray,
|
|
"Partial (linear)",
|
|
printer_serial,
|
|
printer_id=printer_id,
|
|
)
|
|
if spools_updated > 0:
|
|
logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
|
|
|
|
|
|
async def report_usage(printer_id: int, archive_id: int):
|
|
"""Report filament usage to Spoolman after print completion.
|
|
|
|
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:
|
|
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("[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)", printer_id, archive_id)
|
|
return
|
|
|
|
filament_usage = tracking.filament_usage or []
|
|
ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
|
|
slot_to_tray = tracking.slot_to_tray
|
|
tray_remain_start = tracking.tray_remain_start or {}
|
|
# ``layer_usage`` and ``filament_properties`` were added later than
|
|
# the base tracking fields; use ``getattr`` so tests that stub
|
|
# ``tracking`` as a lightweight SimpleNamespace stay valid, and
|
|
# historic ORM rows loaded without these columns can't AttributeError
|
|
# on read.
|
|
layer_usage_raw = getattr(tracking, "layer_usage", None) or {}
|
|
filament_properties = getattr(tracking, "filament_properties", None) or {}
|
|
printer_serial = await _get_printer_serial(printer_id)
|
|
|
|
# Delete tracking row (we're done with it)
|
|
await db.delete(tracking)
|
|
await db.commit()
|
|
|
|
if not filament_usage and not tray_remain_start:
|
|
logger.debug("[SPOOLMAN] No usage data or remain-snapshot for archive %s", 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
|
|
|
|
client = await _get_spoolman_client_with_fallback()
|
|
if not client:
|
|
logger.warning("[SPOOLMAN] Not reachable for usage reporting")
|
|
return
|
|
|
|
# Consult the live printer state for the tray-change log written by
|
|
# ``bambu_mqtt.py`` on every mid-print ``tray_now`` change (#957).
|
|
# When there's more than one entry, the print traversed >1 AMS tray
|
|
# and the split path attributes each segment to the tray that was
|
|
# loaded at the time — matches the internal Spool inventory writer
|
|
# in ``usage_tracker.py``. Without this, an AMS-backup runout switch
|
|
# charges the whole slot to the origin spool and pushes it past
|
|
# ``initial_weight`` (#1793).
|
|
#
|
|
# Split only for SINGLE-slot prints — same gate as
|
|
# ``usage_tracker.py:1002``. Multi-slot (multi-colour) prints
|
|
# naturally cycle trays for every colour change, so splitting each
|
|
# slot's grams across ALL tray_change_log entries would attribute
|
|
# slot 1's grams to the segments where slot 2's tray was loaded and
|
|
# vice versa. Multi-slot prints fall through to the existing
|
|
# single-tray path (which uses the stable ``slot_to_tray`` mapping).
|
|
nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
|
|
tray_changes: list[tuple[int, int]] = []
|
|
_state = None
|
|
if len(nonzero_slots) == 1:
|
|
from backend.app.services.printer_manager import printer_manager as _pm
|
|
|
|
_state = _pm.get_status(printer_id)
|
|
if _state is not None:
|
|
tray_changes = list(getattr(_state, "tray_change_log", []) or [])
|
|
_total_layers = int(getattr(_state, "total_layers", 0) or 0) if _state else 0
|
|
_current_layer = int(getattr(_state, "layer_num", 0) or 0) if _state else 0
|
|
# For the linear-fallback denominator when total_layers is 0 (P1S
|
|
# firmware resets it at print end). At completion the current layer
|
|
# is the print's last valid layer.
|
|
_layer_denom_hint = _total_layers or _current_layer
|
|
|
|
slot_colors: dict[int, str] = {}
|
|
handled_global_tray_ids: set[int] = set()
|
|
spools_updated = 0
|
|
|
|
# --- Path 1: 3MF per-slot estimates -----------------------------
|
|
if filament_usage:
|
|
if len(tray_changes) > 1:
|
|
# Tray-split path — attribute per-segment to the tray that
|
|
# was loaded at that time.
|
|
logger.info(
|
|
"[SPOOLMAN] Reporting per-filament usage for archive %s with tray-split "
|
|
"(tray_change_log=%s, denom_layers=%d)",
|
|
archive_id,
|
|
tray_changes,
|
|
_layer_denom_hint,
|
|
)
|
|
# ``tracking.layer_usage`` was serialized to JSON so int keys
|
|
# come back as strings. Restore them for the split math.
|
|
layer_usage = None
|
|
if layer_usage_raw:
|
|
try:
|
|
layer_usage = {
|
|
int(layer): {int(fid): mm for fid, mm in filaments.items()}
|
|
for layer, filaments in layer_usage_raw.items()
|
|
}
|
|
except (TypeError, ValueError, AttributeError):
|
|
# AttributeError catches ``inner.items()`` when the
|
|
# inner value isn't dict-shaped (corrupt JSON row).
|
|
# Missing gcode falls through to the linear-ratio
|
|
# branch inside ``compute_tray_split_grams`` — still
|
|
# gives a correct split, just less precise.
|
|
layer_usage = None
|
|
split_updated, split_handled = await _report_spool_usage_split_by_tray_changes(
|
|
client,
|
|
filament_usage,
|
|
tray_changes,
|
|
ams_trays,
|
|
layer_usage,
|
|
filament_properties,
|
|
_total_layers,
|
|
_layer_denom_hint,
|
|
f"Archive {archive_id}",
|
|
printer_serial,
|
|
printer_id=printer_id,
|
|
slot_colors_out=slot_colors,
|
|
)
|
|
spools_updated += split_updated
|
|
handled_global_tray_ids |= split_handled
|
|
else:
|
|
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:
|
|
logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
|
|
else:
|
|
logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)
|
|
|
|
# Stamp the archive's filament colour from the matched Spoolman spools
|
|
# so it reflects the curated inventory colour, not the slicer's 3MF
|
|
# value (#1494) — mirrors the built-in inventory path in usage_tracker.
|
|
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(
|
|
db,
|
|
archive_id: int,
|
|
filament_usage: list[dict],
|
|
slot_colors: dict[int, str],
|
|
) -> None:
|
|
"""Overwrite an archive's ``filament_color`` with the colours of the
|
|
Spoolman spools that fed the print (#1494).
|
|
|
|
All-or-nothing, exactly like the built-in inventory path: the colour is
|
|
only rewritten when every used slot resolved to a spool that carries a
|
|
colour, so a partial match never drops slots from the archive.
|
|
"""
|
|
if not slot_colors:
|
|
return
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.services.usage_tracker import (
|
|
_archive_colors_from_spools,
|
|
_spool_color_to_hex,
|
|
)
|
|
|
|
results = [{"slot_id": sid, "color": _spool_color_to_hex(hex_)} for sid, hex_ in slot_colors.items()]
|
|
colors = _archive_colors_from_spools(filament_usage, results)
|
|
if not colors:
|
|
return
|
|
|
|
archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
|
|
if archive is None:
|
|
return
|
|
|
|
joined = ",".join(colors)
|
|
if joined != archive.filament_color:
|
|
logger.info(
|
|
"[SPOOLMAN] Archive %s filament_color %r -> %r (from Spoolman spools)",
|
|
archive_id,
|
|
archive.filament_color,
|
|
joined,
|
|
)
|
|
archive.filament_color = joined
|
|
await db.commit()
|