From d167073fde5d7a710e363886d47815172301aa3b Mon Sep 17 00:00:00 2001 From: RNL Date: Thu, 26 Mar 2026 23:07:52 +1100 Subject: [PATCH] added mcp initial trail files --- Dockerfile | 3 +- bambu_run/conf.py | 21 ++ bambu_run/mcp_tools.py | 700 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 1 + docker/supervisord.conf | 12 + native/bambu-run-mcp.service | 15 + native/bambu-run.sh | 18 +- pyproject.toml | 3 + setup.sh | 27 ++ 9 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 bambu_run/mcp_tools.py create mode 100644 native/bambu-run-mcp.service diff --git a/Dockerfile b/Dockerfile index 07bf362..d4500a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \ # Install project and remaining dependencies (pip sees opencv-python already satisfied) COPY pyproject.toml . -RUN pip install --no-cache-dir ".[standalone]" +RUN pip install --no-cache-dir ".[standalone,mcp]" # Copy application code COPY . . @@ -40,5 +40,6 @@ RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf EXPOSE 8000 +EXPOSE 8808 CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/bambu_run/conf.py b/bambu_run/conf.py index 78e849c..a67c0d5 100644 --- a/bambu_run/conf.py +++ b/bambu_run/conf.py @@ -51,5 +51,26 @@ class _Settings: def AUTO_CREATE_BRAND(self): return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab") + # MCP Server settings + @property + def MCP_API_KEY(self): + return get_setting("BAMBU_RUN_MCP_API_KEY", None) + + @property + def MCP_HOST(self): + return get_setting("BAMBU_RUN_MCP_HOST", "0.0.0.0") + + @property + 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) + app_settings = _Settings() diff --git a/bambu_run/mcp_tools.py b/bambu_run/mcp_tools.py new file mode 100644 index 0000000..19823bc --- /dev/null +++ b/bambu_run/mcp_tools.py @@ -0,0 +1,700 @@ +""" +Pure Django ORM query functions for MCP tools. + +Zero dependency on the `mcp` package — returns markdown strings. +RAE can reuse these directly. +""" + +from datetime import timedelta +from decimal import Decimal + +from django.db.models import Avg, Count, Max, Min, Q, Sum +from django.utils import timezone + +from .conf import app_settings + + +def _redact(value, label="[redacted]"): + """Redact sensitive values if MCP_HIDE_SENSITIVE is enabled.""" + if app_settings.MCP_HIDE_SENSITIVE: + return label + return value + + +def _format_duration(minutes): + """Format minutes into human-readable duration.""" + if minutes is None: + return "Unknown" + hours, mins = divmod(int(minutes), 60) + if hours > 0: + return f"{hours}h {mins}m" + return f"{mins}m" + + +def _format_temp(temp): + """Format temperature value.""" + if temp is None: + return "N/A" + return f"{temp}°C" + + +# ─── Tools ─────────────────────────────────────────────────────────────────── + + +def get_printer_status(printer_id=None): + """Current live status of printer(s) including temps, progress, AMS, errors.""" + from .models import Printer, PrinterMetrics + + printers = Printer.objects.filter(is_active=True) + if printer_id: + printers = printers.filter(id=printer_id) + + if not printers.exists(): + return "No printers found." + + parts = [] + for printer in printers: + metric = PrinterMetrics.objects.filter(device=printer).first() + if not metric: + parts.append(f"## {printer.name}\n**No data available yet.**\n") + continue + + state = metric.gcode_state or "Unknown" + lines = [f"## Printer Status: {printer.name}"] + lines.append(f"**Model**: {printer.model} | **Serial**: {_redact(printer.serial_number)}") + lines.append(f"**IP**: {_redact(printer.ip_address)} | **Location**: {printer.location or 'N/A'}") + lines.append(f"**State**: {state}") + + if metric.print_percent is not None and state == "RUNNING": + layer_info = "" + if metric.layer_num is not None and metric.total_layer_num: + layer_info = f" (Layer {metric.layer_num}/{metric.total_layer_num})" + lines.append(f"**Progress**: {metric.print_percent}%{layer_info}") + if metric.subtask_name: + lines.append(f"**Project**: {metric.subtask_name}") + if metric.remaining_time_min: + lines.append(f"**ETA**: {_format_duration(metric.remaining_time_min)} remaining") + + # Temperatures + lines.append("") + lines.append("### Temperatures") + lines.append("| Component | Current | Target |") + lines.append("|-----------|---------|--------|") + lines.append(f"| Nozzle | {_format_temp(metric.nozzle_temp)} | {_format_temp(metric.nozzle_target_temp)} |") + lines.append(f"| Bed | {_format_temp(metric.bed_temp)} | {_format_temp(metric.bed_target_temp)} |") + lines.append(f"| Chamber | {_format_temp(metric.chamber_temp)} | - |") + + # AMS filaments from JSON + if metric.filaments: + lines.append("") + lines.append("### AMS Slots") + lines.append("| Slot | Material | Color | Remaining |") + lines.append("|------|----------|-------|-----------|") + for f in metric.filaments: + slot = f.get("slot", "?") + ftype = f.get("sub_type") or f.get("type", "?") + color = f.get("color", "") + color_display = f"#{color[:6]}" if color and len(color) >= 6 else "?" + remain = f.get("remain_percent", "?") + lines.append(f"| {slot} | {ftype} | {color_display} | {remain}% |") + + # Errors + if metric.has_errors or metric.hms: + lines.append("") + lines.append("### Alerts") + if metric.print_error: + lines.append(f"- Print error code: {metric.print_error}") + if metric.hms: + for msg in metric.hms[:5]: + lines.append(f"- HMS: {msg}") + + lines.append(f"\n*Last updated: {metric.timestamp.strftime('%Y-%m-%d %H:%M:%S')}*") + parts.append("\n".join(lines)) + + return "\n\n---\n\n".join(parts) + + +def list_printers(): + """List all registered printers.""" + from .models import Printer + + printers = Printer.objects.all() + if not printers.exists(): + return "No printers registered." + + lines = ["# Printers", ""] + lines.append("| ID | Name | Model | Active | Serial | IP | Location |") + lines.append("|----|------|-------|--------|--------|----|----------|") + for p in printers: + lines.append( + f"| {p.id} | {p.name} | {p.model} | " + f"{'Yes' if p.is_active else 'No'} | " + f"{_redact(p.serial_number)} | {_redact(p.ip_address)} | " + f"{p.location or '-'} |" + ) + return "\n".join(lines) + + +def get_print_history(status=None, days=None, project_name=None, limit=20): + """Print job history with optional filters.""" + from .models import PrintJob + + qs = PrintJob.objects.select_related("device") + + if status: + qs = qs.filter(final_status__iexact=status) + if days: + cutoff = timezone.now() - timedelta(days=int(days)) + qs = qs.filter(start_time__gte=cutoff) + if project_name: + qs = qs.filter(project_name__icontains=project_name) + + jobs = qs[:int(limit)] + if not jobs: + return "No print jobs found matching the criteria." + + lines = ["# Print History", ""] + lines.append("| ID | Project | Printer | Status | Progress | Duration | Started |") + lines.append("|----|---------|---------|--------|----------|----------|---------|") + for j in jobs: + lines.append( + f"| {j.id} | {j.project_name} | {j.device.name} | " + f"{j.final_status or 'In Progress'} | {j.completion_percent}% | " + f"{_format_duration(j.duration_minutes)} | " + f"{j.start_time.strftime('%Y-%m-%d %H:%M')} |" + ) + return "\n".join(lines) + + +def get_print_job_detail(job_id): + """Single job detail including filament usage.""" + from .models import FilamentUsage, PrintJob + + try: + job = PrintJob.objects.select_related("device").get(id=job_id) + except PrintJob.DoesNotExist: + return f"Print job #{job_id} not found." + + lines = [f"# Print Job: {job.project_name}", ""] + lines.append(f"**Printer**: {job.device.name}") + lines.append(f"**Status**: {job.final_status or 'In Progress'}") + lines.append(f"**Progress**: {job.completion_percent}%") + if job.gcode_file: + lines.append(f"**G-code**: {job.gcode_file}") + lines.append(f"**Started**: {job.start_time.strftime('%Y-%m-%d %H:%M:%S')}") + if job.end_time: + lines.append(f"**Ended**: {job.end_time.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"**Duration**: {_format_duration(job.duration_minutes)}") + if job.total_layers: + lines.append(f"**Total Layers**: {job.total_layers}") + + # Filament usage + usages = FilamentUsage.objects.select_related("filament").filter(print_job=job) + if usages.exists(): + lines.append("") + lines.append("### Filament Usage") + lines.append("| Spool | Material | Color | Consumed | Grams |") + lines.append("|-------|----------|-------|----------|-------|") + for u in usages: + f = u.filament + lines.append( + f"| {f.brand} {f.type} | {f.sub_type or f.type} | " + f"{f.color} | {u.consumed_percent or 0}% | " + f"{u.consumed_grams or '-'}g |" + ) + + return "\n".join(lines) + + +def list_filaments(type=None, brand=None, color=None, loaded_in_ams=None, low_filament=None): + """Filament inventory with optional filters.""" + from .models import Filament + + qs = Filament.objects.all() + if type: + qs = qs.filter(type__iexact=type) + if brand: + qs = qs.filter(brand__icontains=brand) + if color: + qs = qs.filter(color__icontains=color) + if loaded_in_ams is not None: + qs = qs.filter(is_loaded_in_ams=loaded_in_ams) + if low_filament: + qs = qs.filter(remaining_percent__lte=20) + + filaments = qs[:50] + if not filaments: + return "No filaments found matching the criteria." + + lines = ["# Filament Inventory", ""] + lines.append(f"*{qs.count()} spools total*\n") + lines.append("| ID | Brand | Type | Color | Remaining | In AMS | Last Used |") + lines.append("|----|-------|------|-------|-----------|--------|-----------|") + for f in filaments: + color_display = f"{f.color}" + if f.color_hex: + color_display += f" ({f.color_hex})" + last_used = f.last_used.strftime("%Y-%m-%d") if f.last_used else "-" + lines.append( + f"| {f.id} | {f.brand} | {f.sub_type or f.type} | " + f"{color_display} | {f.remaining_percent}% | " + f"{'Yes' if f.is_loaded_in_ams else 'No'} | {last_used} |" + ) + return "\n".join(lines) + + +def get_filament_detail(filament_id): + """Single spool detail with usage history.""" + from .models import Filament, FilamentUsage + + try: + f = Filament.objects.get(id=filament_id) + except Filament.DoesNotExist: + return f"Filament #{filament_id} not found." + + lines = [f"# Filament: {f.brand} {f.type} - {f.color}", ""] + lines.append(f"**Type**: {f.sub_type or f.type}") + lines.append(f"**Brand**: {f.brand}") + lines.append(f"**Color**: {f.color} ({f.color_hex or 'N/A'})") + lines.append(f"**Remaining**: {f.remaining_percent}%") + if f.remaining_weight_grams: + lines.append(f"**Remaining Weight**: {f.remaining_weight_grams}g / {f.initial_weight_grams or '?'}g") + lines.append(f"**In AMS**: {'Yes (slot ' + str(f.current_tray_id) + ')' if f.is_loaded_in_ams else 'No'}") + lines.append(f"**Created By**: {f.created_by}") + if f.tray_uuid: + lines.append(f"**Serial**: {_redact(f.tray_uuid)}") + if f.purchase_date: + lines.append(f"**Purchased**: {f.purchase_date}") + if f.notes: + lines.append(f"**Notes**: {f.notes}") + + # Usage history + usages = FilamentUsage.objects.select_related("print_job").filter(filament=f).order_by("-print_job__start_time")[:10] + if usages.exists(): + lines.append("") + lines.append("### Recent Print Usage") + lines.append("| Job | Date | Consumed | Grams |") + lines.append("|-----|------|----------|-------|") + for u in usages: + lines.append( + f"| {u.print_job.project_name} | " + f"{u.print_job.start_time.strftime('%Y-%m-%d')} | " + f"{u.consumed_percent or 0}% | {u.consumed_grams or '-'}g |" + ) + + return "\n".join(lines) + + +def get_temperature_history(printer_id=None, hours=6, metric="all"): + """Temperature trends as summary stats (avg/min/max) over recent hours.""" + from .models import Printer, PrinterMetrics + + cutoff = timezone.now() - timedelta(hours=int(hours)) + + qs = PrinterMetrics.objects.filter(timestamp__gte=cutoff) + if printer_id: + qs = qs.filter(device_id=printer_id) + + if not qs.exists(): + return f"No temperature data in the last {hours} hours." + + printers = Printer.objects.filter( + id__in=qs.values_list("device_id", flat=True).distinct() + ) + + parts = [f"# Temperature History (last {hours}h)", ""] + for printer in printers: + pqs = qs.filter(device=printer) + stats = pqs.aggregate( + nozzle_avg=Avg("nozzle_temp"), + nozzle_min=Min("nozzle_temp"), + nozzle_max=Max("nozzle_temp"), + bed_avg=Avg("bed_temp"), + bed_min=Min("bed_temp"), + bed_max=Max("bed_temp"), + chamber_avg=Avg("chamber_temp"), + chamber_min=Min("chamber_temp"), + chamber_max=Max("chamber_temp"), + ) + + parts.append(f"## {printer.name}") + parts.append(f"*{pqs.count()} data points*\n") + parts.append("| Sensor | Avg | Min | Max |") + parts.append("|--------|-----|-----|-----|") + + if metric in ("all", "nozzle"): + parts.append( + f"| Nozzle | {_format_temp(stats['nozzle_avg'])} | " + f"{_format_temp(stats['nozzle_min'])} | {_format_temp(stats['nozzle_max'])} |" + ) + if metric in ("all", "bed"): + parts.append( + f"| Bed | {_format_temp(stats['bed_avg'])} | " + f"{_format_temp(stats['bed_min'])} | {_format_temp(stats['bed_max'])} |" + ) + if metric in ("all", "chamber"): + parts.append( + f"| Chamber | {_format_temp(stats['chamber_avg'])} | " + f"{_format_temp(stats['chamber_min'])} | {_format_temp(stats['chamber_max'])} |" + ) + parts.append("") + + return "\n".join(parts) + + +def get_filament_usage_stats(days=30, group_by="type"): + """Aggregate filament consumption statistics.""" + from .models import FilamentUsage + + cutoff = timezone.now() - timedelta(days=int(days)) + qs = FilamentUsage.objects.filter( + print_job__start_time__gte=cutoff, + consumed_grams__isnull=False, + ).select_related("filament") + + if not qs.exists(): + return f"No filament usage data in the last {days} days." + + lines = [f"# Filament Usage Stats (last {days} days)", ""] + + if group_by == "type": + stats = ( + qs.values("filament__type") + .annotate( + total_grams=Sum("consumed_grams"), + total_percent=Sum("consumed_percent"), + job_count=Count("print_job", distinct=True), + ) + .order_by("-total_grams") + ) + lines.append("| Type | Total Grams | Jobs | Avg Grams/Job |") + lines.append("|------|-------------|------|---------------|") + for s in stats: + avg = s["total_grams"] / s["job_count"] if s["job_count"] else 0 + lines.append( + f"| {s['filament__type']} | {s['total_grams']}g | " + f"{s['job_count']} | {avg:.0f}g |" + ) + elif group_by == "color": + stats = ( + qs.values("filament__color", "filament__type") + .annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True)) + .order_by("-total_grams") + ) + lines.append("| Color | Type | Total Grams | Jobs |") + lines.append("|-------|------|-------------|------|") + for s in stats: + lines.append( + f"| {s['filament__color']} | {s['filament__type']} | " + f"{s['total_grams']}g | {s['job_count']} |" + ) + elif group_by == "spool": + stats = ( + qs.values("filament__id", "filament__brand", "filament__type", "filament__color") + .annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True)) + .order_by("-total_grams")[:20] + ) + lines.append("| Spool | Total Grams | Jobs |") + lines.append("|-------|-------------|------|") + for s in stats: + lines.append( + f"| {s['filament__brand']} {s['filament__type']} {s['filament__color']} | " + f"{s['total_grams']}g | {s['job_count']} |" + ) + + return "\n".join(lines) + + +def get_printer_health(printer_id=None): + """Diagnostics: errors, humidity, wifi, recent failed prints.""" + from .models import Printer, PrinterMetrics, PrintJob + + printers = Printer.objects.filter(is_active=True) + if printer_id: + printers = printers.filter(id=printer_id) + + if not printers.exists(): + return "No printers found." + + parts = ["# Printer Health Report", ""] + for printer in printers: + latest = PrinterMetrics.objects.filter(device=printer).first() + if not latest: + parts.append(f"## {printer.name}\n**No data available.**\n") + continue + + parts.append(f"## {printer.name}") + + # Connectivity + parts.append("### Connectivity") + if latest.wifi_signal_dbm is not None: + signal = latest.wifi_signal_dbm + quality = "Excellent" if signal > -50 else "Good" if signal > -60 else "Fair" if signal > -70 else "Poor" + parts.append(f"- WiFi: {signal} dBm ({quality})") + parts.append(f"- Last seen: {latest.timestamp.strftime('%Y-%m-%d %H:%M:%S')}") + age = (timezone.now() - latest.timestamp).total_seconds() + if age > 300: + parts.append(f"- **Warning**: No data for {_format_duration(age / 60)}") + + # AMS environment + if latest.ams_humidity is not None or latest.ams_temp is not None: + parts.append("### AMS Environment") + if latest.ams_humidity is not None: + hum_status = "OK" if latest.ams_humidity < 5 else "High" if latest.ams_humidity < 8 else "Critical" + parts.append(f"- Humidity: {latest.ams_humidity} ({hum_status})") + if latest.ams_temp is not None: + parts.append(f"- Temperature: {latest.ams_temp}°C") + + # HMS errors + if latest.hms: + parts.append("### Active HMS Alerts") + for msg in latest.hms: + parts.append(f"- {msg}") + + # Recent failures + week_ago = timezone.now() - timedelta(days=7) + failed = PrintJob.objects.filter( + device=printer, + start_time__gte=week_ago, + final_status__in=["FAILED", "CANCELLED"], + ) + if failed.exists(): + parts.append(f"### Recent Failures (7d): {failed.count()}") + for job in failed[:5]: + parts.append(f"- {job.project_name} ({job.final_status}) — {job.start_time.strftime('%m-%d %H:%M')}") + + # Success rate + week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago) + total = week_jobs.count() + if total > 0: + success = week_jobs.filter(final_status="FINISH").count() + parts.append(f"\n**7-day success rate**: {success}/{total} ({100 * success // total}%)") + + parts.append("") + + return "\n".join(parts) + + +def search_print_jobs(query): + """Search print jobs by project name or gcode file.""" + from .models import PrintJob + + if not query: + return "Please provide a search query." + + jobs = PrintJob.objects.select_related("device").filter( + Q(project_name__icontains=query) | Q(gcode_file__icontains=query) + )[:20] + + if not jobs: + return f"No print jobs matching '{query}'." + + lines = [f"# Search Results: '{query}'", ""] + lines.append(f"*{len(jobs)} results*\n") + lines.append("| ID | Project | Printer | Status | Date |") + lines.append("|----|---------|---------|--------|------|") + for j in jobs: + lines.append( + f"| {j.id} | {j.project_name} | {j.device.name} | " + f"{j.final_status or 'In Progress'} | {j.start_time.strftime('%Y-%m-%d')} |" + ) + return "\n".join(lines) + + +def get_printing_summary(days=7): + """High-level activity summary.""" + from .models import FilamentUsage, Printer, PrintJob + + cutoff = timezone.now() - timedelta(days=int(days)) + jobs = PrintJob.objects.filter(start_time__gte=cutoff) + + total = jobs.count() + finished = jobs.filter(final_status="FINISH").count() + failed = jobs.filter(final_status="FAILED").count() + cancelled = jobs.filter(final_status="CANCELLED").count() + in_progress = jobs.filter(final_status__isnull=True).count() + + total_minutes = jobs.filter(duration_minutes__isnull=False).aggregate( + total=Sum("duration_minutes") + )["total"] or 0 + + total_grams = FilamentUsage.objects.filter( + print_job__start_time__gte=cutoff, + consumed_grams__isnull=False, + ).aggregate(total=Sum("consumed_grams"))["total"] or 0 + + lines = [f"# Printing Summary (last {days} days)", ""] + lines.append(f"**Total Jobs**: {total}") + lines.append(f"- Completed: {finished}") + lines.append(f"- Failed: {failed}") + lines.append(f"- Cancelled: {cancelled}") + lines.append(f"- In Progress: {in_progress}") + if total > 0: + lines.append(f"- Success Rate: {100 * finished // total}%") + lines.append(f"\n**Total Print Time**: {_format_duration(total_minutes)}") + lines.append(f"**Total Filament Used**: {total_grams}g") + + # Most printed projects + top_projects = ( + jobs.values("project_name") + .annotate(count=Count("id")) + .order_by("-count")[:5] + ) + if top_projects: + lines.append("\n### Most Printed") + for p in top_projects: + lines.append(f"- {p['project_name']} ({p['count']}x)") + + # Active printers + active_printers = Printer.objects.filter( + print_jobs__start_time__gte=cutoff + ).distinct() + if active_printers.exists(): + lines.append(f"\n**Active Printers**: {', '.join(p.name for p in active_printers)}") + + return "\n".join(lines) + + +def find_compatible_filament(type, min_remaining_percent=10, color=None): + """Find spools matching material type criteria.""" + from .models import Filament + + qs = Filament.objects.filter( + type__iexact=type, + remaining_percent__gte=int(min_remaining_percent), + ) + if color: + qs = qs.filter(color__icontains=color) + + filaments = qs[:20] + if not filaments: + return f"No {type} filament found with >={min_remaining_percent}% remaining." + + lines = [f"# Compatible Filament: {type}", ""] + if color: + lines.append(f"*Color filter: {color}*\n") + lines.append(f"*{qs.count()} spools found*\n") + lines.append("| ID | Brand | Sub-type | Color | Remaining | In AMS |") + lines.append("|----|-------|----------|-------|-----------|--------|") + for f in filaments: + lines.append( + f"| {f.id} | {f.brand} | {f.sub_type or f.type} | " + f"{f.color} | {f.remaining_percent}% | " + f"{'Yes' if f.is_loaded_in_ams else 'No'} |" + ) + return "\n".join(lines) + + +# ─── Resources ─────────────────────────────────────────────────────────────── + + +def resource_printers(): + """List all printers (resource).""" + return list_printers() + + +def resource_printer_status(printer_id): + """Latest printer status (resource).""" + return get_printer_status(printer_id=printer_id) + + +def resource_filaments(): + """Full filament inventory (resource).""" + return list_filaments() + + +def resource_filament_detail(filament_id): + """Single spool with usage (resource).""" + return get_filament_detail(filament_id=filament_id) + + +def resource_recent_print_jobs(): + """Last 20 print jobs (resource).""" + return get_print_history(limit=20) + + +def resource_filament_types(): + """Filament type registry (resource).""" + from .models import FilamentType + + types = FilamentType.objects.all() + if not types.exists(): + return "No filament types registered." + + lines = ["# Filament Types", ""] + lines.append("| ID | Type | Sub-type | Brand |") + lines.append("|----|------|----------|-------|") + for t in types: + lines.append(f"| {t.id} | {t.type} | {t.sub_type or '-'} | {t.brand} |") + return "\n".join(lines) + + +def resource_filament_colors(): + """Filament color database (resource).""" + from .models import FilamentColor + + colors = FilamentColor.objects.all()[:100] + if not colors: + return "No filament colors in database." + + lines = ["# Filament Colors", ""] + lines.append(f"*Showing up to 100 of {FilamentColor.objects.count()}*\n") + lines.append("| Color | Hex | Type | Sub-type | Brand |") + lines.append("|-------|-----|------|----------|-------|") + for c in colors: + lines.append( + f"| {c.color_name} | #{c.color_code} | {c.filament_type} | " + f"{c.filament_sub_type or '-'} | {c.brand} |" + ) + return "\n".join(lines) + + +# ─── Prompts ───────────────────────────────────────────────────────────────── + + +def prompt_printer_check_in(printer_id=None): + """Full status briefing: status + health + recent prints.""" + parts = [ + get_printer_status(printer_id=printer_id), + get_printer_health(printer_id=printer_id), + get_print_history(days=1, limit=5), + ] + return "\n\n---\n\n".join(parts) + + +def prompt_filament_inventory_report(): + """Inventory report with low-stock warnings.""" + from .models import Filament + + low_stock = Filament.objects.filter(remaining_percent__lte=20) + parts = [list_filaments()] + if low_stock.exists(): + lines = ["\n## Low Stock Warnings"] + for f in low_stock: + lines.append(f"- **{f.brand} {f.type} {f.color}**: {f.remaining_percent}% remaining") + parts.append("\n".join(lines)) + return "\n\n".join(parts) + + +def prompt_print_job_review(job_id): + """Review a completed job.""" + return get_print_job_detail(job_id) + + +def prompt_weekly_digest(): + """Weekly activity summary.""" + parts = [ + get_printing_summary(days=7), + get_filament_usage_stats(days=7, group_by="type"), + ] + return "\n\n---\n\n".join(parts) + + +def prompt_troubleshoot_printer(printer_id=None): + """Diagnose issues from recent data.""" + parts = [ + get_printer_health(printer_id=printer_id), + get_printer_status(printer_id=printer_id), + get_temperature_history(printer_id=printer_id, hours=2), + ] + return "\n\n---\n\n".join(parts) diff --git a/docker-compose.yml b/docker-compose.yml index fbb8d90..d3c5d52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: . ports: - "8000:8000" + - "8808:8808" env_file: .env volumes: - bambu_data:/app/data diff --git a/docker/supervisord.conf b/docker/supervisord.conf index e4fa908..76c1d6a 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -25,6 +25,18 @@ autorestart=true startretries=10 startsecs=5 +[program:mcp_server] +command=python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808 +directory=/app +environment=DJANGO_SETTINGS_MODULE="standalone.settings" +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 +autorestart=true +startretries=10 +startsecs=5 + [program:migrate] command=python standalone/manage.py migrate --noinput directory=/app diff --git a/native/bambu-run-mcp.service b/native/bambu-run-mcp.service new file mode 100644 index 0000000..1cd0219 --- /dev/null +++ b/native/bambu-run-mcp.service @@ -0,0 +1,15 @@ +[Unit] +Description=Bambu-Run MCP Server +After=network.target + +[Service] +Type=exec +WorkingDirectory={{REPO_DIR}} +EnvironmentFile={{REPO_DIR}}/.env +Environment=DJANGO_SETTINGS_MODULE=standalone.settings +ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808 +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/native/bambu-run.sh b/native/bambu-run.sh index 9541c00..d796907 100755 --- a/native/bambu-run.sh +++ b/native/bambu-run.sh @@ -8,6 +8,12 @@ VENV_DIR="$REPO_DIR/.venv" MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py" SERVICES="bambu-run-web.service bambu-run-collector.service" +# Include MCP service if installed +SERVICE_DIR="$HOME/.config/systemd/user" +if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then + SERVICES="$SERVICES bambu-run-mcp.service" +fi + case "${1:-help}" in start) systemctl --user start $SERVICES @@ -25,14 +31,22 @@ case "${1:-help}" in systemctl --user status $SERVICES --no-pager ;; logs) - journalctl --user -u bambu-run-web -u bambu-run-collector -f --no-hostname + JOURNAL_UNITS="-u bambu-run-web -u bambu-run-collector" + if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then + JOURNAL_UNITS="$JOURNAL_UNITS -u bambu-run-mcp" + fi + journalctl --user $JOURNAL_UNITS -f --no-hostname ;; update) echo "Pulling latest code..." cd "$REPO_DIR" && git pull echo "Installing dependencies..." - "$VENV_DIR/bin/pip" install --quiet ".[standalone]" + EXTRAS="standalone" + if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then + EXTRAS="standalone,mcp" + fi + "$VENV_DIR/bin/pip" install --quiet ".[$EXTRAS]" echo "Running migrations..." $MANAGE migrate --noinput diff --git a/pyproject.toml b/pyproject.toml index 763f49d..eec88f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ standalone = [ "python-dotenv", "whitenoise", ] +mcp = [ + "mcp[cli]>=1.0", +] dev = [ "ruff", "pytest", diff --git a/setup.sh b/setup.sh index e6a1563..2c6893a 100755 --- a/setup.sh +++ b/setup.sh @@ -201,6 +201,25 @@ sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \ systemctl --user daemon-reload systemctl --user enable bambu-run-web.service bambu-run-collector.service +# ── 9b. Optional MCP server ───────────────────────────────────────────────── + +echo +MCP_ENABLED=false +read -rp "Enable MCP server for AI agent access (Claude Desktop, Claude Code, etc.)? [y/N] " ENABLE_MCP +if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then + green "Installing MCP dependencies..." + "$VENV_DIR/bin/pip" install --quiet ".[mcp]" + + sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \ + "$REPO_DIR/native/bambu-run-mcp.service" > "$SERVICE_DIR/bambu-run-mcp.service" + + systemctl --user daemon-reload + systemctl --user enable bambu-run-mcp.service + systemctl --user start bambu-run-mcp.service + MCP_ENABLED=true + green "MCP server enabled on port 8808." +fi + # Enable linger so services survive SSH logout loginctl enable-linger "$USER" 2>/dev/null || \ sudo loginctl enable-linger "$USER" 2>/dev/null || \ @@ -255,9 +274,17 @@ green " Bambu-Run is running!" green "============================================" echo echo " Dashboard: $DASHBOARD_URL" +if [ "$MCP_ENABLED" = true ]; then + echo " MCP Server: http://${PI_IP:-localhost}:8808/sse" +fi echo " Status: systemctl --user status bambu-run-web bambu-run-collector" echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f" echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}" echo +if [ "$MCP_ENABLED" = true ]; then + echo " Claude Desktop config:" + echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}" + echo +fi echo " Services auto-start on boot. Safe to close SSH." echo