mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-10-21 07:27:38 +01:00
fix(mcp): address final-review findings (sentinel exclusion, per-unit AMS data, generated_at, gating test, docs, cleanup)
This commit is contained in:
35
README.md
35
README.md
@@ -63,6 +63,7 @@ It runs quietly in the background 24/7, capturing every print, filament change,
|
||||
- [Troubleshooting (Native)](#troubleshooting-native)
|
||||
- [Docker Setup](#docker-setup)
|
||||
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
|
||||
- [MCP Server (AI Agent Access)](#mcp-server-ai-agent-access)
|
||||
|
||||
---
|
||||
|
||||
@@ -296,3 +297,37 @@ Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic'
|
||||
- **Created** — new color entries added to the database
|
||||
- **Skipped (duplicate)** — already existed, not changed
|
||||
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet
|
||||
|
||||
## MCP Server (AI Agent Access)
|
||||
|
||||
Bambu-Run exposes printer status, print history, and filament inventory as
|
||||
read-only [MCP](https://modelcontextprotocol.io/) tools, so an AI agent (e.g.
|
||||
Claude Desktop, Claude Code) can query your printer directly.
|
||||
|
||||
Install the extra:
|
||||
|
||||
```bash
|
||||
pip install 'bambu-run[mcp]'
|
||||
```
|
||||
|
||||
**stdio** (for a local agent on the same machine — e.g. Claude Desktop):
|
||||
|
||||
```bash
|
||||
python manage.py bambu_mcp_server --transport stdio
|
||||
```
|
||||
|
||||
**HTTP** (for a remote agent, or multiple callers over the network):
|
||||
|
||||
```bash
|
||||
export BAMBU_RUN_MCP_API_KEY=your-secret-key
|
||||
python manage.py bambu_mcp_server --transport http --host 0.0.0.0 --port 8808
|
||||
```
|
||||
|
||||
`BAMBU_RUN_MCP_API_KEY` is **required** for HTTP — the server refuses to start
|
||||
without it, since an unauthenticated network MCP endpoint would expose your
|
||||
printer and filament data to anyone who can reach the port. stdio needs no key
|
||||
(the OS process boundary is the trust boundary there).
|
||||
|
||||
If serving behind a reverse proxy on a real domain, pass
|
||||
`--allowed-host your.domain.com` (repeatable) — otherwise the SDK's
|
||||
DNS-rebinding guard rejects requests with a real `Host` header.
|
||||
|
||||
@@ -64,14 +64,6 @@ class _Settings:
|
||||
def MCP_PORT(self):
|
||||
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
||||
|
||||
@property
|
||||
def MCP_AUTH_BACKEND(self):
|
||||
return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None)
|
||||
|
||||
@property
|
||||
def MCP_HIDE_SENSITIVE(self):
|
||||
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False)
|
||||
|
||||
# Cloud sync settings
|
||||
@property
|
||||
def CLOUD_SYNC_ENABLED(self):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""list_filaments, get_filament -- the read_filament capability tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from bambu_run.mcp.shapes import normalize_consumed, remaining_status
|
||||
from bambu_run.models import Filament
|
||||
|
||||
@@ -44,7 +46,7 @@ def list_filaments(
|
||||
qs = qs.exclude(remaining_percent=-1).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}
|
||||
return {"generated_at": timezone.now().isoformat(), "filaments": spools}
|
||||
|
||||
|
||||
def _spool_summary(f, include_history):
|
||||
@@ -110,6 +112,7 @@ def get_filament(filament_id: int) -> dict:
|
||||
|
||||
return {
|
||||
**summary,
|
||||
"generated_at": timezone.now().isoformat(),
|
||||
"purchase_date": f.purchase_date.isoformat() if f.purchase_date else None,
|
||||
"usage_history": usages,
|
||||
}
|
||||
|
||||
@@ -31,10 +31,11 @@ def list_print_jobs(
|
||||
returned by one call; narrow days or increase limit deliberately.
|
||||
"""
|
||||
limit = min(limit, 100)
|
||||
now = timezone.now()
|
||||
qs = PrintJob.objects.select_related("cloud_task", "device")
|
||||
if printer is not None:
|
||||
qs = qs.filter(device__in=resolve_printer(printer))
|
||||
since = timezone.now() - timedelta(days=days)
|
||||
since = now - timedelta(days=days)
|
||||
qs = qs.filter(start_time__gte=since)
|
||||
if status == "running":
|
||||
qs = qs.filter(final_status__isnull=True)
|
||||
@@ -44,7 +45,7 @@ def list_print_jobs(
|
||||
qs = qs.filter(project_name__icontains=name_contains)
|
||||
|
||||
jobs = list(qs.order_by("-start_time")[:limit])
|
||||
return {"jobs": [_job_summary(j) for j in jobs]}
|
||||
return {"generated_at": now.isoformat(), "jobs": [_job_summary(j) for j in jobs]}
|
||||
|
||||
|
||||
def _job_summary(job):
|
||||
@@ -79,6 +80,7 @@ def get_print_job(job_id: int) -> dict:
|
||||
per-slot filament breakdown, estimate-vs-actual delta, and linked cloud
|
||||
metadata. Raises PrintJob.DoesNotExist if job_id is unknown.
|
||||
"""
|
||||
now = timezone.now()
|
||||
job = PrintJob.objects.select_related("cloud_task", "device").get(id=job_id)
|
||||
summary = _job_summary(job)
|
||||
|
||||
@@ -109,6 +111,7 @@ def get_print_job(job_id: int) -> dict:
|
||||
cloud = job.cloud_task
|
||||
return {
|
||||
**summary,
|
||||
"generated_at": now.isoformat(),
|
||||
"estimate_delta_percent": delta_percent,
|
||||
"filament_usages": usages,
|
||||
"cloud_design_title": cloud.design_title if cloud else None,
|
||||
@@ -125,7 +128,8 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di
|
||||
most_used_material answers "what did I print most" directly for the
|
||||
requested window (days=7 for last week, days=30 for last month).
|
||||
"""
|
||||
since = timezone.now() - timedelta(days=days)
|
||||
now = timezone.now()
|
||||
since = now - timedelta(days=days)
|
||||
qs = PrintJob.objects.filter(start_time__gte=since)
|
||||
if printer is not None:
|
||||
qs = qs.filter(device__in=resolve_printer(printer))
|
||||
@@ -142,7 +146,7 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di
|
||||
|
||||
cloud_qs = BambuCloudTask.objects.filter(print_jobs__in=qs).distinct()
|
||||
total_grams = cloud_qs.aggregate(s=Sum("weight_grams"))["s"]
|
||||
jobs_without_cloud = total_jobs - cloud_qs.count()
|
||||
jobs_without_cloud = qs.filter(cloud_task__isnull=True).count()
|
||||
|
||||
# Same exclusion rules as normalize_consumed (starting_percent must be a
|
||||
# real reading, and consumption must be non-negative), expressed as a DB
|
||||
@@ -150,6 +154,7 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di
|
||||
valid_usage = (
|
||||
Q(starting_percent__gt=F("ending_percent"))
|
||||
& ~Q(starting_percent=-1)
|
||||
& ~Q(ending_percent=-1)
|
||||
& Q(filament__initial_weight_grams__isnull=False)
|
||||
)
|
||||
material_rows = (
|
||||
@@ -186,6 +191,7 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di
|
||||
median_ratio = statistics.median(ratios) if ratios else None
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"window_days": days,
|
||||
"total_jobs": total_jobs,
|
||||
"success_rate": success_rate,
|
||||
|
||||
@@ -89,9 +89,25 @@ def _temperatures(metric):
|
||||
}
|
||||
|
||||
|
||||
def _safe_tray_id(value, default=-1):
|
||||
"""Coerce a tray_id from the filaments JSON to int, defaulting on anything
|
||||
that isn't a clean int (missing, None, or malformed) rather than crashing
|
||||
the whole tool from one bad row."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _ams_units(printer, latest_metric):
|
||||
"""Build ams_units from the latest metric's filaments, with remain_percent
|
||||
smoothed against the printer's 5 most recent metric rows."""
|
||||
smoothed against the printer's 5 most recent metric rows.
|
||||
|
||||
Per-unit humidity/temp come from PrinterMetrics.ams_units (a JSONField with
|
||||
one entry per physical AMS unit) -- NOT from PrinterMetrics.ams_humidity_raw/
|
||||
.ams_temp, which the collector only ever populates from unit 0's reading and
|
||||
would otherwise stamp one unit's humidity onto every unit.
|
||||
"""
|
||||
recent_filament_lists = list(
|
||||
PrinterMetrics.objects.filter(device=printer)
|
||||
.order_by("-timestamp")
|
||||
@@ -100,19 +116,24 @@ def _ams_units(printer, latest_metric):
|
||||
readings = defaultdict(list)
|
||||
for filaments in recent_filament_lists:
|
||||
for f in filaments or []:
|
||||
key = (f.get("ams_unit_id"), int(f.get("tray_id", -1)))
|
||||
key = (f.get("ams_unit_id"), _safe_tray_id(f.get("tray_id")))
|
||||
readings[key].append(f.get("remain_percent"))
|
||||
|
||||
unit_readings = {
|
||||
u.get("unit_id"): u for u in (latest_metric.ams_units or [])
|
||||
}
|
||||
|
||||
units = {}
|
||||
for f in latest_metric.filaments or []:
|
||||
unit_id = f.get("ams_unit_id")
|
||||
tray_id = int(f.get("tray_id", -1))
|
||||
tray_id = _safe_tray_id(f.get("tray_id"))
|
||||
value, status = smoothed_remaining_percent(readings[(unit_id, tray_id)])
|
||||
unit_reading = unit_readings.get(unit_id, {})
|
||||
unit = units.setdefault(unit_id, {
|
||||
"unit_id": unit_id,
|
||||
"type": f.get("ams_type", ""),
|
||||
"humidity_raw": latest_metric.ams_humidity_raw,
|
||||
"temp_c": float(latest_metric.ams_temp) if latest_metric.ams_temp is not None else None,
|
||||
"humidity_raw": unit_reading.get("humidity_raw"),
|
||||
"temp_c": float(unit_reading["temp"]) if unit_reading.get("temp") is not None else None,
|
||||
"slots": [],
|
||||
})
|
||||
color = f.get("color")
|
||||
@@ -156,12 +177,13 @@ def get_metrics_history(
|
||||
"""
|
||||
from bambu_run.views import sample_metrics # reuse: same thinning the dashboard already uses
|
||||
|
||||
now = timezone.now()
|
||||
max_points = min(max_points, 500)
|
||||
target = resolve_printer(printer).first()
|
||||
if target is None:
|
||||
return {"printer": None, "timestamps": [], "series": {}}
|
||||
return {"generated_at": now.isoformat(), "printer": None, "timestamps": [], "series": {}}
|
||||
|
||||
since = timezone.now() - timedelta(hours=hours)
|
||||
since = now - timedelta(hours=hours)
|
||||
rows = list(
|
||||
PrinterMetrics.objects.filter(device=target, timestamp__gte=since).order_by("timestamp")
|
||||
)
|
||||
@@ -181,6 +203,7 @@ def get_metrics_history(
|
||||
series["cooling_fan_percent"] = [r.cooling_fan_speed for r in rows]
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"printer": {"id": target.id, "name": target.name},
|
||||
"timestamps": [r.timestamp.isoformat() for r in rows],
|
||||
"series": series,
|
||||
|
||||
@@ -15,6 +15,7 @@ def test_list_filaments_filters_material_sub_type_color():
|
||||
|
||||
assert len(result["filaments"]) == 1
|
||||
assert result["filaments"][0]["sub_type"] == "PLA Matte"
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -117,6 +118,7 @@ def test_get_filament_returns_full_usage_history_with_data_quality():
|
||||
assert reliable["consumed_grams"] == 100
|
||||
assert unreliable["data_quality"] == "unreliable"
|
||||
assert unreliable["consumed_grams"] is None
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -16,6 +16,7 @@ def test_list_print_jobs_orders_most_recent_first():
|
||||
result = list_print_jobs()
|
||||
|
||||
assert [j["id"] for j in result["jobs"]] == [newer.id, older.id]
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -86,6 +87,7 @@ def test_get_print_job_marks_negative_consumption_unreliable():
|
||||
usage = result["filament_usages"][0]
|
||||
assert usage["consumed_grams"] is None
|
||||
assert usage["data_quality"] == "unreliable"
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -121,6 +123,21 @@ def test_get_print_statistics_success_rate_and_status_counts():
|
||||
assert result["success_rate"] == 0.5
|
||||
assert result["status_counts"]["FINISH"] == 1
|
||||
assert result["status_counts"]["FAILED"] == 1
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_print_statistics_excludes_ending_sentinel_from_material_mix():
|
||||
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
|
||||
job = PrintJob.objects.create(device=p, project_name="A", start_time=timezone.now(), final_status="FINISH")
|
||||
pla = Filament.objects.create(type="PLA", brand="Bambu Lab", color="Black", initial_weight_grams=1000)
|
||||
# ending_percent=-1 (no RFID read at job end) must not be counted as 610g of consumption
|
||||
FilamentUsage.objects.create(print_job=job, filament=pla, tray_id=0, starting_percent=60, ending_percent=-1)
|
||||
|
||||
result = get_print_statistics(days=30)
|
||||
|
||||
assert result["material_mix_grams"] == {}
|
||||
assert result["most_used_material"] is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -97,6 +97,44 @@ def test_get_printer_status_ams_slot_unread_sentinel():
|
||||
assert slot["remain_status"] == "unread"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_printer_status_ams_units_have_independent_humidity():
|
||||
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
|
||||
PrinterMetrics.objects.create(
|
||||
device=p, timestamp=timezone.now(), gcode_state="RUNNING",
|
||||
ams_humidity_raw=4, # unit-0-only flat column, must NOT be used for unit 1
|
||||
ams_units=[
|
||||
{"unit_id": 0, "ams_type": "AMS 2 Pro", "humidity_raw": 4, "temp": 28.0},
|
||||
{"unit_id": 1, "ams_type": "AMS", "humidity_raw": 7, "temp": 24.0},
|
||||
],
|
||||
filaments=[
|
||||
{"ams_unit_id": 0, "tray_id": "0", "type": "ABS", "remain_percent": 50},
|
||||
{"ams_unit_id": 1, "tray_id": "0", "type": "PLA", "remain_percent": 60},
|
||||
],
|
||||
)
|
||||
|
||||
result = get_printer_status(printer=p.id)
|
||||
|
||||
units_by_id = {u["unit_id"]: u for u in result["printers"][0]["ams_units"]}
|
||||
assert units_by_id[0]["humidity_raw"] == 4
|
||||
assert units_by_id[1]["humidity_raw"] == 7
|
||||
assert units_by_id[0]["temp_c"] == 28.0
|
||||
assert units_by_id[1]["temp_c"] == 24.0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_printer_status_tolerates_malformed_tray_id():
|
||||
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
|
||||
PrinterMetrics.objects.create(
|
||||
device=p, timestamp=timezone.now(), gcode_state="RUNNING",
|
||||
filaments=[{"ams_unit_id": 0, "tray_id": None, "type": "ABS", "remain_percent": 50}],
|
||||
)
|
||||
|
||||
result = get_printer_status(printer=p.id) # must not raise
|
||||
|
||||
assert result["printers"][0]["ams_units"][0]["slots"][0]["slot"] == -1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_printer_status_never_exposes_serial_or_ip():
|
||||
p = Printer.objects.create(
|
||||
@@ -151,6 +189,7 @@ def test_get_metrics_history_returns_series_within_window():
|
||||
assert result["printer"]["id"] == p.id
|
||||
assert len(result["timestamps"]) == 3
|
||||
assert 999.0 not in result["series"]["nozzle_c"]
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -181,7 +220,10 @@ def test_get_metrics_history_hard_ceiling_500_points():
|
||||
@pytest.mark.django_db
|
||||
def test_get_metrics_history_no_matching_printer_returns_empty_shape():
|
||||
result = get_metrics_history(printer=None)
|
||||
assert result == {"printer": None, "timestamps": [], "series": {}}
|
||||
assert result["printer"] is None
|
||||
assert result["timestamps"] == []
|
||||
assert result["series"] == {}
|
||||
assert "generated_at" in result
|
||||
|
||||
|
||||
from bambu_run.models import Hotend
|
||||
|
||||
@@ -56,6 +56,7 @@ async def test_read_printer_only_caller_cannot_see_or_call_filament_tool(db):
|
||||
visible = await client.list_tools()
|
||||
visible_names = {t.name for t in visible.tools}
|
||||
assert "list_filaments" not in visible_names
|
||||
assert "list_print_jobs" not in visible_names
|
||||
assert "get_printer_status" in visible_names
|
||||
|
||||
with pytest.raises(MCPError):
|
||||
|
||||
Reference in New Issue
Block a user