From 212de0b06e67d43eb3094635e8fb599cec068752 Mon Sep 17 00:00:00 2001 From: RNL Date: Wed, 19 Aug 2026 22:14:39 +1000 Subject: [PATCH] feat(mcp): add list_filaments tool --- bambu_run/mcp/filaments.py | 82 +++++++++++++++++++++++++++++++++++++ tests/test_mcp_filaments.py | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 bambu_run/mcp/filaments.py create mode 100644 tests/test_mcp_filaments.py diff --git a/bambu_run/mcp/filaments.py b/bambu_run/mcp/filaments.py new file mode 100644 index 0000000..dd7e847 --- /dev/null +++ b/bambu_run/mcp/filaments.py @@ -0,0 +1,82 @@ +"""list_filaments, get_filament -- the read_filament capability tools.""" +from __future__ import annotations + +from bambu_run.mcp.shapes import normalize_consumed, remaining_status +from bambu_run.models import Filament + + +def list_filaments( + material: str | None = None, + sub_type: str | None = None, + brand: str | None = None, + color: str | None = None, + loaded_only: bool = False, + min_remaining_percent: int | None = None, + min_remaining_grams: int | None = None, + include_history: bool = False, + limit: int = 50, +) -> dict: + """Filament spool inventory with composable filters. + + "PLA Matte Black" is material="PLA", sub_type="PLA Matte", color="black". + "250g of black PLA" is material="PLA", color="black", + min_remaining_grams=250. remaining_percent/remaining_grams are null when + the spool's RFID has never been read (remaining_status is "unread" in + that case, not "low" or "empty"). include_history (default off) adds + each spool's last 3 uses inline; call get_filament for the complete + history of one spool. limit hard ceiling is 100. + """ + limit = min(limit, 100) + qs = Filament.objects.all() + if material: + qs = qs.filter(type__iexact=material) + if sub_type: + qs = qs.filter(sub_type__iexact=sub_type) + if brand: + qs = qs.filter(brand__icontains=brand) + if color: + qs = qs.filter(color__icontains=color) + if loaded_only: + qs = qs.filter(is_loaded_in_ams=True) + if min_remaining_percent is not None: + qs = qs.exclude(remaining_percent=-1).filter(remaining_percent__gte=min_remaining_percent) + if min_remaining_grams is not None: + qs = qs.exclude(remaining_weight_grams__isnull=True).filter(remaining_weight_grams__gte=min_remaining_grams) + + spools = [_spool_summary(f, include_history) for f in qs.order_by("-remaining_percent")[:limit]] + return {"filaments": spools} + + +def _spool_summary(f, include_history): + value, status = remaining_status(f.remaining_percent) + grams = f.remaining_weight_grams + entry = { + "id": f.id, + "material": f.type, + "sub_type": f.sub_type, + "brand": f.brand, + "color_name": f.color, + "color_hex": f.color_hex, + "is_transparent": f.is_transparent, + "remaining_percent": value, + "remaining_status": status, + "remaining_grams": grams if (grams is not None and grams >= 0) else None, + "ams_location": ( + {"unit_id": f.ams_unit_id, "unit_type": f.ams_type, "slot": f.current_tray_id} + if f.is_loaded_in_ams else None + ), + "last_used": f.last_used.isoformat() if f.last_used else None, + } + if include_history: + entry["recent_usage"] = [ + { + "job_id": u.print_job_id, + "job_name": u.print_job.project_name, + "started_at": u.print_job.start_time.isoformat(), + "consumed_grams": normalize_consumed( + u.starting_percent, u.ending_percent, f.initial_weight_grams + )[1], + } + for u in f.print_usages.select_related("print_job").order_by("-print_job__start_time")[:3] + ] + return entry diff --git a/tests/test_mcp_filaments.py b/tests/test_mcp_filaments.py new file mode 100644 index 0000000..35127d0 --- /dev/null +++ b/tests/test_mcp_filaments.py @@ -0,0 +1,79 @@ +import pytest +from django.utils import timezone + +from bambu_run.models import Filament, Printer, PrintJob, FilamentUsage +from bambu_run.mcp.filaments import list_filaments + + +@pytest.mark.django_db +def test_list_filaments_filters_material_sub_type_color(): + Filament.objects.create(type="PLA", sub_type="PLA Matte", brand="Bambu Lab", color="Black", remaining_percent=50) + Filament.objects.create(type="PLA", sub_type="PLA Basic", brand="Bambu Lab", color="Black", remaining_percent=50) + Filament.objects.create(type="ABS", sub_type="ABS", brand="Bambu Lab", color="Black", remaining_percent=50) + + result = list_filaments(material="PLA", sub_type="PLA Matte", color="black") + + assert len(result["filaments"]) == 1 + assert result["filaments"][0]["sub_type"] == "PLA Matte" + + +@pytest.mark.django_db +def test_list_filaments_min_remaining_grams_excludes_unread_sentinel(): + Filament.objects.create(type="ABS", brand="Bambu Lab", color="Mint", remaining_percent=-1, remaining_weight_grams=-10) + Filament.objects.create(type="ABS", brand="Bambu Lab", color="Orange", remaining_percent=68, remaining_weight_grams=680) + + result = list_filaments(material="ABS", min_remaining_grams=250) + + assert len(result["filaments"]) == 1 + assert result["filaments"][0]["color_name"] == "Orange" + + +@pytest.mark.django_db +def test_list_filaments_unread_sentinel_status_not_low(): + Filament.objects.create(type="ABS", brand="Bambu Lab", color="Mint", remaining_percent=-1) + + result = list_filaments(material="ABS") + + spool = result["filaments"][0] + assert spool["remaining_percent"] is None + assert spool["remaining_status"] == "unread" + + +@pytest.mark.django_db +def test_list_filaments_limit_hard_ceiling_100(): + Filament.objects.bulk_create([ + Filament(type="PLA", brand="Bambu Lab", color=f"Color {i}", remaining_percent=50) + for i in range(150) + ]) + + result = list_filaments(limit=1000) + + assert len(result["filaments"]) == 100 + + +@pytest.mark.django_db +def test_list_filaments_include_history_adds_recent_usage(): + f = Filament.objects.create(type="PLA", brand="Bambu Lab", color="White", initial_weight_grams=1000, remaining_percent=80) + p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True) + job = PrintJob.objects.create(device=p, project_name="Vase", start_time=timezone.now(), final_status="FINISH") + FilamentUsage.objects.create(print_job=job, filament=f, tray_id=0, starting_percent=90, ending_percent=80) + + result = list_filaments(material="PLA", include_history=True) + + history = result["filaments"][0]["recent_usage"] + assert history[0]["job_name"] == "Vase" + assert history[0]["consumed_grams"] == 100 + + +@pytest.mark.django_db +def test_list_filaments_never_exposes_rfid_identifiers(): + Filament.objects.create( + type="PLA", brand="Bambu Lab", color="White", + tray_uuid="SECRET-UUID", tag_uid="SECRET-TAG", + ) + + result = list_filaments(material="PLA") + + entry_str = repr(result["filaments"][0]) + assert "SECRET-UUID" not in entry_str + assert "SECRET-TAG" not in entry_str