Add spool inventory: AMS slot assignment, usage tracking, and remaining weight editing

Built-in spool inventory with AMS slot assignment for non-BL spools,
automatic filament usage tracking via 3MF estimates, and remaining
weight editing in the spool form.

Inventory features:
- AMS slot assignment: assign/unassign manual spools to AMS tray slots
- Filter out Bambu Lab spools (RFID-managed) from assignment modal
- Filter out already-assigned spools (one spool per slot)
- Auto-unlink manual assignments when a BL spool is inserted
- Hide assign/unassign UI on BL-occupied slots
- Case-insensitive fingerprint matching for auto-unlink logic

Usage tracking:
- 3MF-based filament consumption tracking for non-BL spools
- Extract per-filament used_g from archived 3MF slice_info
- Map 3MF slot_id to AMS (ams_id, tray_id) positions
- Scale estimates by print progress for failed/aborted prints
- Skip BL spools (tracked via AMS remain% delta, Path 1)
- Prevent double-counting with handled_trays set

Other:
- Add remaining weight field to spool edit form
- Fix hardcoded "Empty" string in AssignSpoolModal (now i18n)
- Add backend unit tests for usage_tracker (11 tests)
- Add frontend tests for AssignSpoolModal filters (5 tests)
- Update CHANGELOG, README, wiki docs, and website
This commit is contained in:
maziggy
2026-02-12 15:42:27 +01:00
parent 1f0931e00c
commit e6e62f2a68
29 changed files with 10404 additions and 9316 deletions
+5 -1
View File
@@ -645,8 +645,12 @@ async def assign_spool(
fingerprint_type = None
state = printer_manager.get_status(data.printer_id)
if state and state.raw_data:
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
)
tray = _find_tray_in_ams_data(
state.raw_data.get("ams", {}).get("ams", []),
ams_list,
data.ams_id,
data.tray_id,
)
+175 -61
View File
@@ -97,90 +97,204 @@ DEFAULT_SPOOL_CATALOG: list[tuple[str, int]] = [
# (manufacturer, color_name, hex_color, material)
DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
# Bambu Lab PLA Basic
# Bambu Lab PLA Basic (from official hex code PDF)
("Bambu Lab", "Jade White", "#FFFFFF", "PLA Basic"),
("Bambu Lab", "Black", "#000000", "PLA Basic"),
("Bambu Lab", "Silver", "#A6A9AA", "PLA Basic"),
("Bambu Lab", "Light Gray", "#C0C0C0", "PLA Basic"),
("Bambu Lab", "Light Gray", "#D1D3D5", "PLA Basic"),
("Bambu Lab", "Gray", "#8E9089", "PLA Basic"),
("Bambu Lab", "Dark Gray", "#616364", "PLA Basic"),
("Bambu Lab", "Dark Gray", "#545454", "PLA Basic"),
("Bambu Lab", "Red", "#C12E1F", "PLA Basic"),
("Bambu Lab", "Maroon Red", "#9D2235", "PLA Basic"),
("Bambu Lab", "Magenta", "#EC008C", "PLA Basic"),
("Bambu Lab", "Hot Pink", "#FF69B4", "PLA Basic"),
("Bambu Lab", "Hot Pink", "#F5547C", "PLA Basic"),
("Bambu Lab", "Pink", "#F55A74", "PLA Basic"),
("Bambu Lab", "Beige", "#F7E6DE", "PLA Basic"),
("Bambu Lab", "Yellow", "#FFFF00", "PLA Basic"),
("Bambu Lab", "Yellow", "#F4EE2A", "PLA Basic"),
("Bambu Lab", "Sunflower Yellow", "#FEC600", "PLA Basic"),
("Bambu Lab", "Gold", "#E4BD68", "PLA Basic"),
("Bambu Lab", "Orange", "#FF8C00", "PLA Basic"),
("Bambu Lab", "Orange", "#FF6A13", "PLA Basic"),
("Bambu Lab", "Pumpkin Orange", "#FF9016", "PLA Basic"),
("Bambu Lab", "Bright Green", "#66FF00", "PLA Basic"),
("Bambu Lab", "Bright Green", "#BECF00", "PLA Basic"),
("Bambu Lab", "Bambu Green", "#00AE42", "PLA Basic"),
("Bambu Lab", "Mistletoe Green", "#3F8E43", "PLA Basic"),
("Bambu Lab", "Turquoise", "#00B1B7", "PLA Basic"),
("Bambu Lab", "Cyan", "#0086D6", "PLA Basic"),
("Bambu Lab", "Blue", "#0A2989", "PLA Basic"),
("Bambu Lab", "Blue Grey", "#647988", "PLA Basic"),
("Bambu Lab", "Cobalt Blue", "#0047AB", "PLA Basic"),
("Bambu Lab", "Blue Grey", "#5B6579", "PLA Basic"),
("Bambu Lab", "Cobalt Blue", "#0056B8", "PLA Basic"),
("Bambu Lab", "Purple", "#5E43B7", "PLA Basic"),
("Bambu Lab", "Indigo Purple", "#482960", "PLA Basic"),
("Bambu Lab", "Brown", "#9D432C", "PLA Basic"),
("Bambu Lab", "Cocoa Brown", "#5C4033", "PLA Basic"),
("Bambu Lab", "Cocoa Brown", "#6F5034", "PLA Basic"),
("Bambu Lab", "Bronze", "#847D48", "PLA Basic"),
# Bambu Lab PLA Matte
("Bambu Lab", "Ivory White", "#EBEBE3", "PLA Matte"),
("Bambu Lab", "Bone White", "#F5F5DC", "PLA Matte"),
("Bambu Lab", "Lemon Yellow", "#FFF44F", "PLA Matte"),
("Bambu Lab", "Mandarin Orange", "#FF7518", "PLA Matte"),
("Bambu Lab", "Scarlet Red", "#FF2400", "PLA Matte"),
("Bambu Lab", "Lilac Purple", "#C8A2C8", "PLA Matte"),
("Bambu Lab", "Grape Purple", "#6F2DA8", "PLA Matte"),
("Bambu Lab", "Grass Green", "#6BB173", "PLA Matte"),
("Bambu Lab", "Dark Green", "#656A4D", "PLA Matte"),
("Bambu Lab", "Sakura Pink", "#EAB8CA", "PLA Matte"),
("Bambu Lab", "Charcoal", "#36454F", "PLA Matte"),
# Bambu Lab PLA Silk
("Bambu Lab", "Blue", "#4F9CCC", "PLA Silk"),
("Bambu Lab", "Gold", "#CFB53B", "PLA Silk"),
("Bambu Lab", "Silver", "#C0C0C0", "PLA Silk"),
("Bambu Lab", "Copper", "#B87333", "PLA Silk"),
("Bambu Lab", "Green", "#50C878", "PLA Silk"),
("Bambu Lab", "Red", "#DC143C", "PLA Silk"),
# Bambu Lab PLA Sparkle
("Bambu Lab", "Alpine Green Sparkle", "#4F6359", "PLA Sparkle"),
("Bambu Lab", "Galaxy Black Sparkle", "#1C1C1C", "PLA Sparkle"),
("Bambu Lab", "Space Gray Sparkle", "#4A4A4A", "PLA Sparkle"),
# Bambu Lab PETG Basic
("Bambu Lab", "Black", "#000000", "PETG Basic"),
("Bambu Lab", "White", "#FFFFFF", "PETG Basic"),
("Bambu Lab", "Gray", "#808080", "PETG Basic"),
("Bambu Lab", "Translucent", "#F0F0F0", "PETG Basic"),
# Bambu Lab PETG-HF
("Bambu Lab", "White", "#F0F1F0", "PETG-HF"),
("Bambu Lab", "Black", "#000000", "PETG-HF"),
("Bambu Lab", "Gray", "#A3A6A6", "PETG-HF"),
("Bambu Lab", "Red", "#C33F45", "PETG-HF"),
("Bambu Lab", "Orange", "#FF7146", "PETG-HF"),
("Bambu Lab", "Blue", "#1E90FF", "PETG-HF"),
("Bambu Lab", "Translucent Orange", "#EF8E5B", "PETG-HF"),
# Bambu Lab ABS
("Bambu Lab", "Black", "#000000", "ABS"),
# Bambu Lab PLA Matte (from official hex code PDF)
("Bambu Lab", "Ivory White", "#FFFFFF", "PLA Matte"),
("Bambu Lab", "Bone White", "#CBC6B8", "PLA Matte"),
("Bambu Lab", "Desert Tan", "#E8DBB7", "PLA Matte"),
("Bambu Lab", "Latte Brown", "#D3B7A7", "PLA Matte"),
("Bambu Lab", "Caramel", "#AE835B", "PLA Matte"),
("Bambu Lab", "Terracotta", "#B15533", "PLA Matte"),
("Bambu Lab", "Dark Brown", "#7D6556", "PLA Matte"),
("Bambu Lab", "Dark Chocolate", "#4D3324", "PLA Matte"),
("Bambu Lab", "Lilac Purple", "#AE96D4", "PLA Matte"),
("Bambu Lab", "Sakura Pink", "#E8AFCF", "PLA Matte"),
("Bambu Lab", "Mandarin Orange", "#F99963", "PLA Matte"),
("Bambu Lab", "Lemon Yellow", "#F7D959", "PLA Matte"),
("Bambu Lab", "Plum", "#950051", "PLA Matte"),
("Bambu Lab", "Scarlet Red", "#DE4343", "PLA Matte"),
("Bambu Lab", "Dark Red", "#BB3D43", "PLA Matte"),
("Bambu Lab", "Dark Green", "#68724D", "PLA Matte"),
("Bambu Lab", "Grass Green", "#61C680", "PLA Matte"),
("Bambu Lab", "Apple Green", "#C2E189", "PLA Matte"),
("Bambu Lab", "Ice Blue", "#A3D8E1", "PLA Matte"),
("Bambu Lab", "Sky Blue", "#56B7E6", "PLA Matte"),
("Bambu Lab", "Marine Blue", "#0078BF", "PLA Matte"),
("Bambu Lab", "Dark Blue", "#042F56", "PLA Matte"),
("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
# Bambu Lab PLA Silk+ (from store page)
("Bambu Lab", "Gold", "#F4A925", "PLA Silk"),
("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk"),
("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk"),
("Bambu Lab", "Blue", "#008BDA", "PLA Silk"),
("Bambu Lab", "Purple", "#8671CB", "PLA Silk"),
("Bambu Lab", "Candy Red", "#D02727", "PLA Silk"),
("Bambu Lab", "Candy Green", "#018814", "PLA Silk"),
("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk"),
("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk"),
("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk"),
("Bambu Lab", "Mint", "#96DCB9", "PLA Silk"),
("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk"),
("Bambu Lab", "White", "#FFFFFF", "PLA Silk"),
# Bambu Lab PLA Sparkle (from store page)
("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),
("Bambu Lab", "Crimson Red Sparkle", "#792B36", "PLA Sparkle"),
("Bambu Lab", "Royal Purple Sparkle", "#483D8B", "PLA Sparkle"),
("Bambu Lab", "Alpine Green Sparkle", "#3F5443", "PLA Sparkle"),
("Bambu Lab", "Onyx Black Sparkle", "#2D2B28", "PLA Sparkle"),
# Bambu Lab PLA Translucent (from official hex code PDF)
("Bambu Lab", "Teal", "#009FA1", "PLA Translucent"),
("Bambu Lab", "Light Jade", "#96D8AF", "PLA Translucent"),
("Bambu Lab", "Blue", "#0047BB", "PLA Translucent"),
("Bambu Lab", "Mellow Yellow", "#F5DBAB", "PLA Translucent"),
("Bambu Lab", "Purple", "#8344B0", "PLA Translucent"),
("Bambu Lab", "Cherry Pink", "#F5B6CD", "PLA Translucent"),
("Bambu Lab", "Orange", "#F74E02", "PLA Translucent"),
("Bambu Lab", "Ice Blue", "#B8CDE9", "PLA Translucent"),
("Bambu Lab", "Red", "#B50011", "PLA Translucent"),
("Bambu Lab", "Lavender", "#B8ACD6", "PLA Translucent"),
# Bambu Lab PLA Glow (from store page)
("Bambu Lab", "Glow Green", "#A1FFAC", "PLA Glow"),
("Bambu Lab", "Glow Yellow", "#F8FF80", "PLA Glow"),
("Bambu Lab", "Glow Pink", "#F17B8F", "PLA Glow"),
("Bambu Lab", "Glow Blue", "#7AC0E9", "PLA Glow"),
("Bambu Lab", "Glow Orange", "#FF9D5B", "PLA Glow"),
# Bambu Lab PLA Galaxy (from store page)
("Bambu Lab", "Brown", "#684A43", "PLA Galaxy"),
("Bambu Lab", "Green", "#3B665E", "PLA Galaxy"),
("Bambu Lab", "Nebulae", "#424379", "PLA Galaxy"),
("Bambu Lab", "Purple", "#594177", "PLA Galaxy"),
# Bambu Lab PLA Metal (from store page)
("Bambu Lab", "Iridium Gold Metallic", "#B39B84", "PLA Metal"),
("Bambu Lab", "Copper Brown Metallic", "#AA6443", "PLA Metal"),
("Bambu Lab", "Oxide Green Metallic", "#1D7C6A", "PLA Metal"),
("Bambu Lab", "Cobalt Blue Metallic", "#39699E", "PLA Metal"),
("Bambu Lab", "Iron Gray Metallic", "#43403D", "PLA Metal"),
# Bambu Lab PLA Marble (from store page)
("Bambu Lab", "White Marble", "#F7F3F0", "PLA Marble"),
("Bambu Lab", "Red Granite", "#AD4E38", "PLA Marble"),
# Bambu Lab PLA Wood (from store page)
("Bambu Lab", "Black Walnut", "#4F3F24", "PLA Wood"),
("Bambu Lab", "Rosewood", "#4C241C", "PLA Wood"),
("Bambu Lab", "Clay Brown", "#995F11", "PLA Wood"),
("Bambu Lab", "Classic Birch", "#918669", "PLA Wood"),
("Bambu Lab", "White Oak", "#D6CCA3", "PLA Wood"),
("Bambu Lab", "Ochre Yellow", "#C98935", "PLA Wood"),
# Bambu Lab PLA Tough+ (from official hex code PDF)
("Bambu Lab", "White", "#FFFFFF", "PLA Tough"),
("Bambu Lab", "Gray", "#AFB1AE", "PLA Tough"),
("Bambu Lab", "Black", "#000000", "PLA Tough"),
("Bambu Lab", "Silver", "#959698", "PLA Tough"),
("Bambu Lab", "Yellow", "#F4D53F", "PLA Tough"),
("Bambu Lab", "Cyan", "#009BD8", "PLA Tough"),
("Bambu Lab", "Orange", "#DC3A27", "PLA Tough"),
# Bambu Lab PLA-CF (from official hex code PDF)
("Bambu Lab", "Burgundy Red", "#951E23", "PLA-CF"),
("Bambu Lab", "Iris Purple", "#69398E", "PLA-CF"),
("Bambu Lab", "Matcha Green", "#5C9748", "PLA-CF"),
("Bambu Lab", "Jeans Blue", "#6E88BC", "PLA-CF"),
("Bambu Lab", "Royal Blue", "#2842AD", "PLA-CF"),
("Bambu Lab", "Lava Gray", "#4D5054", "PLA-CF"),
("Bambu Lab", "Black", "#000000", "PLA-CF"),
# Bambu Lab ABS (from official hex code PDF)
("Bambu Lab", "White", "#FFFFFF", "ABS"),
("Bambu Lab", "Gray", "#808080", "ABS"),
("Bambu Lab", "Red", "#FF0000", "ABS"),
# Bambu Lab ASA
("Bambu Lab", "Desert Tan", "#E8DBB7", "ABS"),
("Bambu Lab", "Olive", "#789D4A", "ABS"),
("Bambu Lab", "Azure", "#489FDF", "ABS"),
("Bambu Lab", "Navy Blue", "#0C2340", "ABS"),
("Bambu Lab", "Blue", "#0A2CA5", "ABS"),
("Bambu Lab", "Tangerine Yellow", "#FFC72C", "ABS"),
("Bambu Lab", "Orange", "#FF6A13", "ABS"),
("Bambu Lab", "Red", "#D32941", "ABS"),
("Bambu Lab", "Purple", "#AF1685", "ABS"),
("Bambu Lab", "Silver", "#87909A", "ABS"),
("Bambu Lab", "Black", "#000000", "ABS"),
# Bambu Lab ASA (from store page)
("Bambu Lab", "White", "#FFFAF2", "ASA"),
("Bambu Lab", "Gray", "#8A949E", "ASA"),
("Bambu Lab", "Red", "#E02928", "ASA"),
("Bambu Lab", "Green", "#00A6A0", "ASA"),
("Bambu Lab", "Blue", "#2140B4", "ASA"),
("Bambu Lab", "Black", "#000000", "ASA"),
("Bambu Lab", "White", "#FFFFFF", "ASA"),
("Bambu Lab", "Gray", "#808080", "ASA"),
# Bambu Lab TPU
("Bambu Lab", "White", "#F0EFE3", "TPU 95A"),
("Bambu Lab", "Black", "#000000", "TPU 95A"),
("Bambu Lab", "Gray", "#8C9091", "TPU 95A"),
("Bambu Lab", "Red", "#FF0000", "TPU 95A"),
# Bambu Lab PLA-CF / PAHT-CF / PETG-CF
("Bambu Lab", "Black", "#1A1A1A", "PLA-CF"),
# Bambu Lab PETG HF (from store page)
("Bambu Lab", "Yellow", "#FFD00B", "PETG HF"),
("Bambu Lab", "Orange", "#F75403", "PETG HF"),
("Bambu Lab", "Green", "#00AE42", "PETG HF"),
("Bambu Lab", "Red", "#EB3A3A", "PETG HF"),
("Bambu Lab", "Blue", "#002E96", "PETG HF"),
("Bambu Lab", "Black", "#000000", "PETG HF"),
("Bambu Lab", "White", "#FFFFFF", "PETG HF"),
("Bambu Lab", "Cream", "#F9DFB9", "PETG HF"),
("Bambu Lab", "Lime Green", "#6EE53C", "PETG HF"),
("Bambu Lab", "Forest Green", "#39541A", "PETG HF"),
("Bambu Lab", "Lake Blue", "#1F79E5", "PETG HF"),
("Bambu Lab", "Peanut Brown", "#875718", "PETG HF"),
("Bambu Lab", "Gray", "#ADB1B2", "PETG HF"),
("Bambu Lab", "Dark Gray", "#515151", "PETG HF"),
# Bambu Lab PETG Translucent (from store page)
("Bambu Lab", "Translucent Gray", "#8E8E8E", "PETG Translucent"),
("Bambu Lab", "Translucent Light Blue", "#61B0FF", "PETG Translucent"),
("Bambu Lab", "Translucent Olive", "#748C45", "PETG Translucent"),
("Bambu Lab", "Translucent Brown", "#C9A381", "PETG Translucent"),
("Bambu Lab", "Translucent Teal", "#77EDD7", "PETG Translucent"),
("Bambu Lab", "Translucent Orange", "#FF911A", "PETG Translucent"),
("Bambu Lab", "Translucent Purple", "#D6ABFF", "PETG Translucent"),
("Bambu Lab", "Translucent Pink", "#F9C1BD", "PETG Translucent"),
# Bambu Lab PETG-CF (from official hex code PDF)
("Bambu Lab", "Brick Red", "#9F332A", "PETG-CF"),
("Bambu Lab", "Violet Purple", "#583061", "PETG-CF"),
("Bambu Lab", "Indigo Blue", "#324585", "PETG-CF"),
("Bambu Lab", "Malachite Green", "#16B08E", "PETG-CF"),
("Bambu Lab", "Black", "#000000", "PETG-CF"),
("Bambu Lab", "Titan Gray", "#565656", "PETG-CF"),
# Bambu Lab TPU 95A HF (from store page)
("Bambu Lab", "White", "#FFFFFF", "TPU 95A"),
("Bambu Lab", "Yellow", "#F3E600", "TPU 95A"),
("Bambu Lab", "Blue", "#0072CE", "TPU 95A"),
("Bambu Lab", "Red", "#C8102E", "TPU 95A"),
("Bambu Lab", "Gray", "#898D8D", "TPU 95A"),
("Bambu Lab", "Black", "#101820", "TPU 95A"),
# Bambu Lab TPU 90A (from official hex code PDF)
("Bambu Lab", "Black", "#000000", "TPU 90A"),
("Bambu Lab", "White", "#FFFFFF", "TPU 90A"),
("Bambu Lab", "Grape Jelly", "#D6ABFF", "TPU 90A"),
("Bambu Lab", "Crystal Blue", "#7EB4E1", "TPU 90A"),
("Bambu Lab", "Cocoa Brown", "#5C4738", "TPU 90A"),
# Bambu Lab PAHT-CF
("Bambu Lab", "Black", "#1A1A1A", "PAHT-CF"),
("Bambu Lab", "Black", "#1A1A1A", "PETG-CF"),
# Bambu Lab Support Materials
("Bambu Lab", "Natural", "#F5F5DC", "PLA Support"),
("Bambu Lab", "Natural", "#F5F5DC", "PVA Support"),
+51 -14
View File
@@ -541,30 +541,65 @@ async def on_ams_change(printer_id: int, ams_data: list):
for assignment in result.scalars().all():
current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)
if not current_tray:
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — tray not found in AMS data (slot empty?)",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
)
stale.append(assignment) # Slot empty
elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
# A Bambu Lab spool was inserted — always unlink manual assignments
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — Bambu Lab spool detected (uuid=%s)",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
current_tray.get("tray_uuid", ""),
)
stale.append(assignment)
else:
cur_color = current_tray.get("tray_color", "")
cur_type = current_tray.get("tray_type", "")
fp_color = assignment.fingerprint_color or ""
fp_type = assignment.fingerprint_type or ""
if cur_color != fp_color or cur_type != fp_type:
stale.append(assignment) # Spool changed
elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
# Only unlink if the assigned spool doesn't match this tag
tray_uuid = current_tray.get("tray_uuid", "")
tag_uid = current_tray.get("tag_uid", "")
if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
# Fingerprint mismatch — but check if tray now matches the
# assigned spool (e.g. auto-configure changed the tray).
spool = assignment.spool
if spool and (
(spool.tray_uuid and spool.tray_uuid == tray_uuid)
or (spool.tag_uid and spool.tag_uid == tag_uid)
):
continue # Same spool — keep assignment
stale.append(assignment) # Different Bambu spool inserted
if spool:
spool_color = (spool.rgba or "FFFFFFFF").upper()
spool_type = (spool.material or "").upper()
if cur_color.upper() == spool_color and cur_type.upper() == spool_type:
# Tray was reconfigured to match the spool — update fingerprint
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
)
assignment.fingerprint_color = cur_color
assignment.fingerprint_type = cur_type
continue
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch (cur=%s/%s fp=%s/%s spool=%s/%s)",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
cur_color,
cur_type,
fp_color,
fp_type,
spool.rgba if spool else "?",
spool.material if spool else "?",
)
stale.append(assignment) # Spool changed
for a in stale:
await db.delete(a)
if stale:
await db.commit()
logger.info("Auto-unlinked %d stale spool assignments for printer %d", len(stale), printer_id)
# Commit any changes (stale deletions and/or fingerprint updates)
await db.commit()
except Exception as e:
logger.warning("Spool assignment cleanup failed: %s", e)
@@ -2051,7 +2086,9 @@ async def on_print_complete(printer_id: int, data: dict):
from backend.app.services.usage_tracker import on_print_complete as usage_on_print_complete
async with async_session() as db:
usage_results = await usage_on_print_complete(printer_id, data, printer_manager, db)
usage_results = await usage_on_print_complete(
printer_id, data, printer_manager, db, archive_id=archive_id
)
if usage_results:
await ws_manager.broadcast(
{
+2 -2
View File
@@ -519,7 +519,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
trays.append(
{
"id": tray.get("id", 0),
"id": int(tray.get("id", 0)),
"tray_color": tray.get("tray_color"),
"tray_type": tray.get("tray_type"),
"tray_sub_brands": tray.get("tray_sub_brands"),
@@ -556,7 +556,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
ams_units.append(
{
"id": ams_data.get("id", 0),
"id": int(ams_data.get("id", 0)),
"humidity": humidity_value,
"temp": ams_data.get("temp"),
"is_ams_ht": is_ams_ht,
+244 -95
View File
@@ -2,6 +2,9 @@
Captures AMS tray remain% at print start, then computes consumption
deltas at print complete to update spool weight_used and last_used.
For non-BL spools (no RFID, AMS reports remain=-1), falls back to
per-filament usage estimates from the archived 3MF file.
"""
import logging
@@ -37,7 +40,8 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
return
ams_data = state.raw_data.get("ams", {}).get("ams", [])
ams_raw = state.raw_data.get("ams", [])
ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
if not ams_data:
logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
return
@@ -51,12 +55,10 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
if isinstance(remain, int) and 0 <= remain <= 100:
tray_remain_start[(ams_id, tray_id)] = remain
if not tray_remain_start:
logger.debug("[UsageTracker] No valid remain%% data for printer %d", printer_id)
return
print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
# Always create session (even without valid remain data) so print_name
# is available at completion for 3MF-based tracking
session = PrintSession(
printer_id=printer_id,
print_name=print_name,
@@ -64,12 +66,16 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
tray_remain_start=tray_remain_start,
)
_active_sessions[printer_id] = session
logger.info(
"[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
printer_id,
len(tray_remain_start),
{f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
)
if tray_remain_start:
logger.info(
"[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
printer_id,
len(tray_remain_start),
{f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
)
else:
logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
async def on_print_complete(
@@ -77,103 +83,246 @@ async def on_print_complete(
data: dict,
printer_manager,
db: AsyncSession,
archive_id: int | None = None,
) -> list[dict]:
"""Compute consumption deltas and update spool weight_used/last_used.
Uses two tracking strategies:
1. AMS remain% delta — for BL spools with valid RFID remain data
2. 3MF per-filament estimates — for non-BL spools without remain data
Returns a list of dicts describing what was logged (for WebSocket broadcast).
"""
session = _active_sessions.pop(printer_id, None)
if not session:
logger.debug("[UsageTracker] No active session for printer %d, skipping", printer_id)
return []
# Read current remain%
state = printer_manager.get_status(printer_id)
if not state or not state.raw_data:
logger.warning("[UsageTracker] No state at print complete for printer %d", printer_id)
return []
ams_data = state.raw_data.get("ams", {}).get("ams", [])
status = data.get("status", "completed")
results = []
handled_trays: set[tuple[int, int]] = set()
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))
key = (ams_id, tray_id)
if key not in session.tray_remain_start:
continue
current_remain = tray.get("remain", -1)
if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
continue
start_remain = session.tray_remain_start[key]
delta_pct = start_remain - current_remain
if delta_pct <= 0:
continue # No consumption or tray was refilled
# Look up SpoolAssignment for this slot
result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Compute weight consumed
weight_grams = (delta_pct / 100.0) * spool.label_weight
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=session.print_name,
weight_used=round(weight_grams, 1),
percent_used=delta_pct,
status=status,
)
db.add(history)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": delta_pct,
"ams_id": ams_id,
"tray_id": tray_id,
}
# --- Path 1: AMS remain% delta (for spools with valid RFID remain data) ---
if session and session.tray_remain_start:
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
ams_raw = state.raw_data.get("ams", [])
ams_data = (
ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
)
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
spool.id,
weight_grams,
delta_pct,
printer_id,
ams_id,
tray_id,
status,
)
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))
key = (ams_id, tray_id)
if key not in session.tray_remain_start:
continue
current_remain = tray.get("remain", -1)
if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
continue
start_remain = session.tray_remain_start[key]
delta_pct = start_remain - current_remain
if delta_pct <= 0:
continue # No consumption or tray was refilled
# Look up SpoolAssignment for this slot
result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Compute weight consumed
weight_grams = (delta_pct / 100.0) * spool.label_weight
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=session.print_name,
weight_used=round(weight_grams, 1),
percent_used=delta_pct,
status=status,
)
db.add(history)
handled_trays.add(key)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": delta_pct,
"ams_id": ams_id,
"tray_id": tray_id,
}
)
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
spool.id,
weight_grams,
delta_pct,
printer_id,
ams_id,
tray_id,
status,
)
# --- Path 2: 3MF per-filament estimates (for non-BL spools without remain data) ---
if archive_id:
print_name = (
(session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
)
threemf_results = await _track_from_3mf(
printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
)
results.extend(threemf_results)
if results:
await db.commit()
return results
async def _track_from_3mf(
printer_id: int,
archive_id: int,
status: str,
print_name: str,
handled_trays: set[tuple[int, int]],
printer_manager,
db: AsyncSession,
) -> list[dict]:
"""Track usage from 3MF per-filament data for non-BL spools.
Falls back to slicer-estimated filament weight when AMS remain% is
unavailable (non-RFID spools). For partial prints (failed/aborted),
scales the estimate by print progress.
"""
from backend.app.core.config import settings as app_settings
from backend.app.models.archive import PrintArchive
from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
archive = result.scalar_one_or_none()
if not archive or not archive.file_path:
return []
file_path = app_settings.base_dir / archive.file_path
if not file_path.exists():
return []
filament_usage = extract_filament_usage_from_3mf(file_path)
if not filament_usage:
return []
# Scale factor for partial prints (failed/aborted)
if status == "completed":
scale = 1.0
else:
state = printer_manager.get_status(printer_id)
progress = state.progress if state else 0
scale = max(0.0, min(progress / 100.0, 1.0))
results = []
for usage in filament_usage:
slot_id = usage.get("slot_id", 0)
used_g = usage.get("used_g", 0)
if used_g <= 0:
continue
# Map 3MF slot_id (1-based) to (ams_id, tray_id)
global_tray_id = slot_id - 1
if global_tray_id >= 128:
ams_id = global_tray_id
tray_id = 0
else:
ams_id = global_tray_id // 4
tray_id = global_tray_id % 4
key = (ams_id, tray_id)
if key in handled_trays:
continue # Already tracked via AMS remain% delta
# Find spool assignment for this tray
assign_result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = assign_result.scalar_one_or_none()
if not assignment:
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Only use 3MF tracking for non-BL spools (BL spools use AMS remain%)
if spool.tag_uid or spool.tray_uuid:
continue
weight_grams = used_g * scale
if weight_grams <= 0:
continue
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
percent = round(weight_grams / (spool.label_weight or 1000) * 100)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=print_name,
weight_used=round(weight_grams, 1),
percent_used=percent,
status=status,
)
db.add(history)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": percent,
"ams_id": ams_id,
"tray_id": tray_id,
}
)
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (3MF estimate%s) on printer %d AMS%d-T%d (%s)",
spool.id,
weight_grams,
f" scaled to {scale:.0%}" if scale < 1 else "",
printer_id,
ams_id,
tray_id,
status,
)
return results
@@ -0,0 +1,388 @@
"""Unit tests for the filament usage tracker.
Tests both AMS remain% delta tracking (Path 1) and 3MF per-filament
fallback tracking (Path 2) for non-BL spools.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.usage_tracker import (
PrintSession,
_active_sessions,
_track_from_3mf,
on_print_complete,
on_print_start,
)
def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
"""Create a mock Spool object."""
spool = MagicMock()
spool.id = id
spool.label_weight = label_weight
spool.weight_used = weight_used
spool.tag_uid = tag_uid
spool.tray_uuid = tray_uuid
spool.last_used = None
return spool
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
"""Create a mock SpoolAssignment object."""
assignment = MagicMock()
assignment.spool_id = spool_id
assignment.printer_id = printer_id
assignment.ams_id = ams_id
assignment.tray_id = tray_id
return assignment
def _make_printer_state(ams_data, progress=0):
"""Create a mock printer state with AMS data."""
state = MagicMock()
state.raw_data = {"ams": ams_data}
state.progress = progress
return state
def _make_printer_manager(state=None):
"""Create a mock printer manager."""
pm = MagicMock()
pm.get_status.return_value = state
return pm
class TestOnPrintStart:
"""Tests for on_print_start — capturing AMS remain%."""
@pytest.fixture(autouse=True)
def _clear_sessions(self):
_active_sessions.clear()
yield
_active_sessions.clear()
@pytest.mark.asyncio
async def test_creates_session_with_valid_remain(self):
"""Session created with remain% data for trays reporting 0-100."""
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
await on_print_start(1, {"subtask_name": "test_print"}, pm)
assert 1 in _active_sessions
session = _active_sessions[1]
assert session.print_name == "test_print"
assert session.tray_remain_start == {(0, 0): 80}
@pytest.mark.asyncio
async def test_creates_session_even_without_valid_remain(self):
"""Session still created when remain=-1 (for 3MF fallback path)."""
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
await on_print_start(1, {"subtask_name": "test_print"}, pm)
assert 1 in _active_sessions
session = _active_sessions[1]
assert session.tray_remain_start == {} # Empty, no valid remain
@pytest.mark.asyncio
async def test_skips_without_ams_data(self):
"""No session created when no AMS data available."""
state = MagicMock()
state.raw_data = {"ams": []}
pm = _make_printer_manager(state)
await on_print_start(1, {"subtask_name": "test"}, pm)
assert 1 not in _active_sessions
class TestOnPrintCompleteAMSDelta:
"""Tests for Path 1: AMS remain% delta tracking."""
@pytest.fixture(autouse=True)
def _clear_sessions(self):
_active_sessions.clear()
yield
_active_sessions.clear()
@pytest.mark.asyncio
async def test_computes_delta_and_updates_spool(self):
"""Spool weight_used updated by remain% delta * label_weight."""
# Set up session with start remain = 80%
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="test",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
)
# Current remain = 70% → 10% consumed → 100g on 1000g spool
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
spool = _make_spool(label_weight=1000, weight_used=50)
assignment = _make_assignment()
db = AsyncMock()
# First execute → assignment, second → spool
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
results = await on_print_complete(1, {"status": "completed"}, pm, db)
assert len(results) == 1
assert results[0]["weight_used"] == 100.0
assert results[0]["percent_used"] == 10
# weight_used should be old (50) + delta (100)
assert spool.weight_used == 150.0
db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_skips_negative_delta(self):
"""No tracking when remain increased (spool refilled)."""
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="test",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 50},
)
# Remain went UP: 50 → 80 (refilled)
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
db = AsyncMock()
results = await on_print_complete(1, {"status": "completed"}, pm, db)
assert results == []
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_no_session_falls_through_to_3mf(self):
"""When no session exists, AMS delta path skipped (3MF may still run)."""
pm = _make_printer_manager()
db = AsyncMock()
results = await on_print_complete(1, {"status": "completed"}, pm, db)
assert results == []
class TestTrackFrom3MF:
"""Tests for Path 2: 3MF per-filament fallback tracking."""
@pytest.mark.asyncio
async def test_updates_non_bl_spool_from_3mf(self):
"""Non-BL spool gets weight_used from 3MF used_g for completed print."""
spool = _make_spool(id=5, label_weight=1000, weight_used=100)
assignment = _make_assignment(spool_id=5)
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
# First execute → archive, second → assignment, third → spool
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test_print",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert len(results) == 1
assert results[0]["spool_id"] == 5
assert results[0]["weight_used"] == 25.5
# weight_used = old (100) + 3MF (25.5)
assert spool.weight_used == 125.5
@pytest.mark.asyncio
async def test_scales_by_progress_for_failed_print(self):
"""Failed print scales 3MF estimate by progress percentage."""
spool = _make_spool(id=1, label_weight=1000, weight_used=0)
assignment = _make_assignment()
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
# Print failed at 50% progress → 50g consumed from 100g estimate
pm = _make_printer_manager(_make_printer_state([], progress=50))
filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="failed",
print_name="test",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert len(results) == 1
assert results[0]["weight_used"] == 50.0
assert spool.weight_used == 50.0
@pytest.mark.asyncio
async def test_skips_bl_spools(self):
"""BL spools (with tag_uid) are NOT tracked via 3MF — they use AMS remain%."""
spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
assignment = _make_assignment()
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert results == []
@pytest.mark.asyncio
async def test_skips_already_handled_trays(self):
"""Trays handled by AMS remain% delta are not double-tracked via 3MF."""
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test",
handled_trays={(0, 0)}, # slot_id=1 → ams_id=0, tray_id=0
printer_manager=pm,
db=db,
)
assert results == []
@pytest.mark.asyncio
async def test_slot_to_tray_mapping(self):
"""3MF slot_id maps correctly to (ams_id, tray_id)."""
# slot 5 → global_tray_id 4 → ams_id=1, tray_id=0
spool = _make_spool(id=9)
assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert len(results) == 1
assert results[0]["ams_id"] == 1
assert results[0]["tray_id"] == 0