mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user