mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-10-21 15:37: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)
|
- [Troubleshooting (Native)](#troubleshooting-native)
|
||||||
- [Docker Setup](#docker-setup)
|
- [Docker Setup](#docker-setup)
|
||||||
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
|
- [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
|
- **Created** — new color entries added to the database
|
||||||
- **Skipped (duplicate)** — already existed, not changed
|
- **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
|
- **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):
|
def MCP_PORT(self):
|
||||||
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
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
|
# Cloud sync settings
|
||||||
@property
|
@property
|
||||||
def CLOUD_SYNC_ENABLED(self):
|
def CLOUD_SYNC_ENABLED(self):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""list_filaments, get_filament -- the read_filament capability tools."""
|
"""list_filaments, get_filament -- the read_filament capability tools."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bambu_run.mcp.shapes import normalize_consumed, remaining_status
|
from bambu_run.mcp.shapes import normalize_consumed, remaining_status
|
||||||
from bambu_run.models import Filament
|
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)
|
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]]
|
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):
|
def _spool_summary(f, include_history):
|
||||||
@@ -110,6 +112,7 @@ def get_filament(filament_id: int) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
**summary,
|
**summary,
|
||||||
|
"generated_at": timezone.now().isoformat(),
|
||||||
"purchase_date": f.purchase_date.isoformat() if f.purchase_date else None,
|
"purchase_date": f.purchase_date.isoformat() if f.purchase_date else None,
|
||||||
"usage_history": usages,
|
"usage_history": usages,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ def list_print_jobs(
|
|||||||
returned by one call; narrow days or increase limit deliberately.
|
returned by one call; narrow days or increase limit deliberately.
|
||||||
"""
|
"""
|
||||||
limit = min(limit, 100)
|
limit = min(limit, 100)
|
||||||
|
now = timezone.now()
|
||||||
qs = PrintJob.objects.select_related("cloud_task", "device")
|
qs = PrintJob.objects.select_related("cloud_task", "device")
|
||||||
if printer is not None:
|
if printer is not None:
|
||||||
qs = qs.filter(device__in=resolve_printer(printer))
|
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)
|
qs = qs.filter(start_time__gte=since)
|
||||||
if status == "running":
|
if status == "running":
|
||||||
qs = qs.filter(final_status__isnull=True)
|
qs = qs.filter(final_status__isnull=True)
|
||||||
@@ -44,7 +45,7 @@ def list_print_jobs(
|
|||||||
qs = qs.filter(project_name__icontains=name_contains)
|
qs = qs.filter(project_name__icontains=name_contains)
|
||||||
|
|
||||||
jobs = list(qs.order_by("-start_time")[:limit])
|
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):
|
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
|
per-slot filament breakdown, estimate-vs-actual delta, and linked cloud
|
||||||
metadata. Raises PrintJob.DoesNotExist if job_id is unknown.
|
metadata. Raises PrintJob.DoesNotExist if job_id is unknown.
|
||||||
"""
|
"""
|
||||||
|
now = timezone.now()
|
||||||
job = PrintJob.objects.select_related("cloud_task", "device").get(id=job_id)
|
job = PrintJob.objects.select_related("cloud_task", "device").get(id=job_id)
|
||||||
summary = _job_summary(job)
|
summary = _job_summary(job)
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ def get_print_job(job_id: int) -> dict:
|
|||||||
cloud = job.cloud_task
|
cloud = job.cloud_task
|
||||||
return {
|
return {
|
||||||
**summary,
|
**summary,
|
||||||
|
"generated_at": now.isoformat(),
|
||||||
"estimate_delta_percent": delta_percent,
|
"estimate_delta_percent": delta_percent,
|
||||||
"filament_usages": usages,
|
"filament_usages": usages,
|
||||||
"cloud_design_title": cloud.design_title if cloud else None,
|
"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
|
most_used_material answers "what did I print most" directly for the
|
||||||
requested window (days=7 for last week, days=30 for last month).
|
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)
|
qs = PrintJob.objects.filter(start_time__gte=since)
|
||||||
if printer is not None:
|
if printer is not None:
|
||||||
qs = qs.filter(device__in=resolve_printer(printer))
|
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()
|
cloud_qs = BambuCloudTask.objects.filter(print_jobs__in=qs).distinct()
|
||||||
total_grams = cloud_qs.aggregate(s=Sum("weight_grams"))["s"]
|
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
|
# Same exclusion rules as normalize_consumed (starting_percent must be a
|
||||||
# real reading, and consumption must be non-negative), expressed as a DB
|
# 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 = (
|
valid_usage = (
|
||||||
Q(starting_percent__gt=F("ending_percent"))
|
Q(starting_percent__gt=F("ending_percent"))
|
||||||
& ~Q(starting_percent=-1)
|
& ~Q(starting_percent=-1)
|
||||||
|
& ~Q(ending_percent=-1)
|
||||||
& Q(filament__initial_weight_grams__isnull=False)
|
& Q(filament__initial_weight_grams__isnull=False)
|
||||||
)
|
)
|
||||||
material_rows = (
|
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
|
median_ratio = statistics.median(ratios) if ratios else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"generated_at": now.isoformat(),
|
||||||
"window_days": days,
|
"window_days": days,
|
||||||
"total_jobs": total_jobs,
|
"total_jobs": total_jobs,
|
||||||
"success_rate": success_rate,
|
"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):
|
def _ams_units(printer, latest_metric):
|
||||||
"""Build ams_units from the latest metric's filaments, with remain_percent
|
"""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(
|
recent_filament_lists = list(
|
||||||
PrinterMetrics.objects.filter(device=printer)
|
PrinterMetrics.objects.filter(device=printer)
|
||||||
.order_by("-timestamp")
|
.order_by("-timestamp")
|
||||||
@@ -100,19 +116,24 @@ def _ams_units(printer, latest_metric):
|
|||||||
readings = defaultdict(list)
|
readings = defaultdict(list)
|
||||||
for filaments in recent_filament_lists:
|
for filaments in recent_filament_lists:
|
||||||
for f in filaments or []:
|
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"))
|
readings[key].append(f.get("remain_percent"))
|
||||||
|
|
||||||
|
unit_readings = {
|
||||||
|
u.get("unit_id"): u for u in (latest_metric.ams_units or [])
|
||||||
|
}
|
||||||
|
|
||||||
units = {}
|
units = {}
|
||||||
for f in latest_metric.filaments or []:
|
for f in latest_metric.filaments or []:
|
||||||
unit_id = f.get("ams_unit_id")
|
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)])
|
value, status = smoothed_remaining_percent(readings[(unit_id, tray_id)])
|
||||||
|
unit_reading = unit_readings.get(unit_id, {})
|
||||||
unit = units.setdefault(unit_id, {
|
unit = units.setdefault(unit_id, {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"type": f.get("ams_type", ""),
|
"type": f.get("ams_type", ""),
|
||||||
"humidity_raw": latest_metric.ams_humidity_raw,
|
"humidity_raw": unit_reading.get("humidity_raw"),
|
||||||
"temp_c": float(latest_metric.ams_temp) if latest_metric.ams_temp is not None else None,
|
"temp_c": float(unit_reading["temp"]) if unit_reading.get("temp") is not None else None,
|
||||||
"slots": [],
|
"slots": [],
|
||||||
})
|
})
|
||||||
color = f.get("color")
|
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
|
from bambu_run.views import sample_metrics # reuse: same thinning the dashboard already uses
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
max_points = min(max_points, 500)
|
max_points = min(max_points, 500)
|
||||||
target = resolve_printer(printer).first()
|
target = resolve_printer(printer).first()
|
||||||
if target is None:
|
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(
|
rows = list(
|
||||||
PrinterMetrics.objects.filter(device=target, timestamp__gte=since).order_by("timestamp")
|
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]
|
series["cooling_fan_percent"] = [r.cooling_fan_speed for r in rows]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"generated_at": now.isoformat(),
|
||||||
"printer": {"id": target.id, "name": target.name},
|
"printer": {"id": target.id, "name": target.name},
|
||||||
"timestamps": [r.timestamp.isoformat() for r in rows],
|
"timestamps": [r.timestamp.isoformat() for r in rows],
|
||||||
"series": series,
|
"series": series,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def test_list_filaments_filters_material_sub_type_color():
|
|||||||
|
|
||||||
assert len(result["filaments"]) == 1
|
assert len(result["filaments"]) == 1
|
||||||
assert result["filaments"][0]["sub_type"] == "PLA Matte"
|
assert result["filaments"][0]["sub_type"] == "PLA Matte"
|
||||||
|
assert "generated_at" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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 reliable["consumed_grams"] == 100
|
||||||
assert unreliable["data_quality"] == "unreliable"
|
assert unreliable["data_quality"] == "unreliable"
|
||||||
assert unreliable["consumed_grams"] is None
|
assert unreliable["consumed_grams"] is None
|
||||||
|
assert "generated_at" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def test_list_print_jobs_orders_most_recent_first():
|
|||||||
result = list_print_jobs()
|
result = list_print_jobs()
|
||||||
|
|
||||||
assert [j["id"] for j in result["jobs"]] == [newer.id, older.id]
|
assert [j["id"] for j in result["jobs"]] == [newer.id, older.id]
|
||||||
|
assert "generated_at" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -86,6 +87,7 @@ def test_get_print_job_marks_negative_consumption_unreliable():
|
|||||||
usage = result["filament_usages"][0]
|
usage = result["filament_usages"][0]
|
||||||
assert usage["consumed_grams"] is None
|
assert usage["consumed_grams"] is None
|
||||||
assert usage["data_quality"] == "unreliable"
|
assert usage["data_quality"] == "unreliable"
|
||||||
|
assert "generated_at" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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["success_rate"] == 0.5
|
||||||
assert result["status_counts"]["FINISH"] == 1
|
assert result["status_counts"]["FINISH"] == 1
|
||||||
assert result["status_counts"]["FAILED"] == 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
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -97,6 +97,44 @@ def test_get_printer_status_ams_slot_unread_sentinel():
|
|||||||
assert slot["remain_status"] == "unread"
|
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
|
@pytest.mark.django_db
|
||||||
def test_get_printer_status_never_exposes_serial_or_ip():
|
def test_get_printer_status_never_exposes_serial_or_ip():
|
||||||
p = Printer.objects.create(
|
p = Printer.objects.create(
|
||||||
@@ -151,6 +189,7 @@ def test_get_metrics_history_returns_series_within_window():
|
|||||||
assert result["printer"]["id"] == p.id
|
assert result["printer"]["id"] == p.id
|
||||||
assert len(result["timestamps"]) == 3
|
assert len(result["timestamps"]) == 3
|
||||||
assert 999.0 not in result["series"]["nozzle_c"]
|
assert 999.0 not in result["series"]["nozzle_c"]
|
||||||
|
assert "generated_at" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -181,7 +220,10 @@ def test_get_metrics_history_hard_ceiling_500_points():
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_metrics_history_no_matching_printer_returns_empty_shape():
|
def test_get_metrics_history_no_matching_printer_returns_empty_shape():
|
||||||
result = get_metrics_history(printer=None)
|
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
|
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 = await client.list_tools()
|
||||||
visible_names = {t.name for t in visible.tools}
|
visible_names = {t.name for t in visible.tools}
|
||||||
assert "list_filaments" not in visible_names
|
assert "list_filaments" not in visible_names
|
||||||
|
assert "list_print_jobs" not in visible_names
|
||||||
assert "get_printer_status" in visible_names
|
assert "get_printer_status" in visible_names
|
||||||
|
|
||||||
with pytest.raises(MCPError):
|
with pytest.raises(MCPError):
|
||||||
|
|||||||
Reference in New Issue
Block a user