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/admin.py b/bambu_run/admin.py index fdb879b..03d7989 100644 --- a/bambu_run/admin.py +++ b/bambu_run/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage +from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask @admin.register(Printer) @@ -105,3 +105,21 @@ class FilamentUsageAdmin(admin.ModelAdmin): list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary') list_filter = ('is_primary', 'tray_id') readonly_fields = ('consumed_percent', 'consumed_grams') + + +@admin.register(BambuCloudTask) +class BambuCloudTaskAdmin(admin.ModelAdmin): + list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at') + list_filter = ('cloud_status', 'use_ams', 'bed_type') + search_fields = ('design_title', 'plate_title', 'device_serial', 'task_id') + readonly_fields = ('task_id', 'synced_at', 'raw_data') + date_hierarchy = 'cloud_start_time' + + fieldsets = ( + ('Identity', {'fields': ('task_id', 'design_id', 'design_title', 'plate_title', 'model_id', 'profile_id', 'plate_index')}), + ('Device & Print', {'fields': ('device_serial', 'cloud_status', 'bed_type', 'use_ams', 'print_mode')}), + ('Filament', {'fields': ('weight_grams', 'length_mm', 'ams_detail_mapping')}), + ('Times', {'fields': ('cloud_start_time', 'cloud_end_time', 'cost_time_seconds', 'synced_at')}), + ('Media', {'fields': ('cover_url',)}), + ('Raw', {'fields': ('raw_data',), 'classes': ('collapse',)}), + ) diff --git a/bambu_run/bambu_cloud.py b/bambu_run/bambu_cloud.py new file mode 100644 index 0000000..ddbbb11 --- /dev/null +++ b/bambu_run/bambu_cloud.py @@ -0,0 +1,121 @@ +""" +Thin wrapper around the Bambu Cloud HTTP API using verified endpoints only. + +Uses BambuClient as the transport (auth headers, base URL) but bypasses +the package's named methods, which contain guessed/unverified endpoints. + +All functions take a BambuClient instance as first argument. +""" + +import logging +from datetime import timezone as dt_timezone + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Verified HTTP wrappers +# --------------------------------------------------------------------------- + +def get_tasks(client, limit=20, offset=0): + """Fetch recent cloud tasks. Returns the raw response dict.""" + return client.get('v1/user-service/my/tasks', params={'limit': limit, 'offset': offset}) + + +def get_profile(client): + """Fetch the authenticated user's profile.""" + return client.get('v1/user-service/my/profile') + + +# --------------------------------------------------------------------------- +# Upsert helpers +# --------------------------------------------------------------------------- + +def _parse_cloud_dt(value): + """Parse an ISO-8601 string like '2026-03-28T12:38:29Z' to aware datetime.""" + if not value: + return None + from django.utils.dateparse import parse_datetime + from django.utils import timezone + dt = parse_datetime(value) + if dt and dt.tzinfo is None: + dt = dt.replace(tzinfo=dt_timezone.utc) + return dt + + +def upsert_cloud_task(task_dict): + """ + Parse one task dict from the cloud API and upsert into BambuCloudTask. + + Returns the (BambuCloudTask instance, created bool) tuple. + """ + from .models import BambuCloudTask + + task_id = task_dict.get('id') + if not task_id: + raise ValueError("task_dict has no 'id' field") + + defaults = { + 'design_id': task_dict.get('designId') or None, + 'design_title': task_dict.get('designTitle') or '', + 'plate_title': task_dict.get('title') or '', + 'model_id': task_dict.get('modelId') or '', + 'profile_id': task_dict.get('profileId') or None, + 'plate_index': task_dict.get('plateIndex'), + 'device_serial': task_dict.get('deviceId') or '', + 'cover_url': task_dict.get('cover') or '', + 'weight_grams': task_dict.get('weight'), + 'length_mm': task_dict.get('length'), + 'cost_time_seconds': task_dict.get('costTime'), + 'cloud_status': task_dict.get('status'), + 'bed_type': task_dict.get('bedType') or '', + 'use_ams': bool(task_dict.get('useAms', True)), + 'print_mode': task_dict.get('mode') or '', + 'ams_detail_mapping': task_dict.get('amsDetailMapping') or [], + 'cloud_start_time': _parse_cloud_dt(task_dict.get('startTime')), + 'cloud_end_time': _parse_cloud_dt(task_dict.get('endTime')), + 'raw_data': task_dict, + } + + return BambuCloudTask.objects.update_or_create(task_id=task_id, defaults=defaults) + + +def fetch_and_upsert_task(client, print_job): + """ + Called by bambu_collector at print finalization. + + Fetches recent tasks from cloud, finds the one matching print_job.cloud_task_id_raw, + upserts BambuCloudTask, and wires up the FK on print_job. + + Non-fatal: all errors are logged as warnings only. + """ + if not print_job.cloud_task_id_raw: + logger.debug(f"Job #{print_job.id} has no cloud_task_id_raw — skipping cloud sync") + return + + try: + response = get_tasks(client, limit=20) + hits = response.get('hits', response.get('tasks', [])) + except Exception as e: + logger.warning(f"Cloud tasks fetch failed for job #{print_job.id}: {e}") + return + + target = next((t for t in hits if t.get('id') == print_job.cloud_task_id_raw), None) + if not target: + logger.warning( + f"Job #{print_job.id}: cloud task {print_job.cloud_task_id_raw} " + f"not found in last {len(hits)} tasks from API" + ) + return + + try: + cloud_task, created = upsert_cloud_task(target) + print_job.cloud_task = cloud_task + print_job.save(update_fields=['cloud_task']) + action = 'created' if created else 'updated' + logger.info( + f"Job #{print_job.id}: cloud task {print_job.cloud_task_id_raw} {action} " + f"— design_title={cloud_task.design_title!r}" + ) + except Exception as e: + logger.warning(f"Cloud task upsert failed for job #{print_job.id}: {e}") diff --git a/bambu_run/conf.py b/bambu_run/conf.py index 78e849c..dd4a020 100644 --- a/bambu_run/conf.py +++ b/bambu_run/conf.py @@ -51,5 +51,35 @@ 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) + + # Cloud sync settings + @property + def CLOUD_SYNC_ENABLED(self): + return get_setting("BAMBU_RUN_CLOUD_SYNC_ENABLED", True) + + @property + def CLOUD_SYNC_DAYS(self): + return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30) + app_settings = _Settings() diff --git a/bambu_run/management/commands/bambu_collector.py b/bambu_run/management/commands/bambu_collector.py index 1f097e8..a6a0a2d 100644 --- a/bambu_run/management/commands/bambu_collector.py +++ b/bambu_run/management/commands/bambu_collector.py @@ -473,6 +473,7 @@ class Command(BaseCommand): if self.current_print_job: self._finalize_print_job(metric, snapshot) + raw_task_id = snapshot.get('task_id') self.current_print_job = PrintJob.objects.create( device=self.printer_device, project_name=subtask_name, @@ -480,7 +481,8 @@ class Command(BaseCommand): start_time=metric.timestamp, start_metric=metric, total_layers=snapshot.get('total_layer_num'), - completion_percent=snapshot.get('print_percent', 0) + completion_percent=snapshot.get('print_percent', 0), + cloud_task_id_raw=int(raw_task_id) if raw_task_id else None, ) self.trays_used = set() logger.info(f"Print job started: {subtask_name}") @@ -520,6 +522,12 @@ class Command(BaseCommand): self.current_print_job.calculate_duration() self.current_print_job.save() + try: + from bambu_run.bambu_cloud import fetch_and_upsert_task + fetch_and_upsert_task(self.printer_client._client, self.current_print_job) + except Exception as e: + logger.warning(f"Cloud task sync skipped (non-fatal): {e}") + start_metric = self.current_print_job.start_metric if not start_metric: logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage") diff --git a/bambu_run/management/commands/bambu_mcp_server.py b/bambu_run/management/commands/bambu_mcp_server.py new file mode 100644 index 0000000..6f80ccf --- /dev/null +++ b/bambu_run/management/commands/bambu_mcp_server.py @@ -0,0 +1,355 @@ +""" +Management command to run the Bambu-Run MCP server. + +Supports SSE (network) and stdio (local) transports. + +Usage: + python manage.py bambu_mcp_server + python manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808 + python manage.py bambu_mcp_server --transport stdio +""" + +import logging + +from django.core.management.base import BaseCommand, CommandError + +logger = logging.getLogger("bambu_run.mcp") + + +class Command(BaseCommand): + help = "Run the Bambu-Run MCP server for AI agent access" + + def add_arguments(self, parser): + from bambu_run.conf import app_settings + + parser.add_argument( + "--transport", + choices=["sse", "stdio"], + default="sse", + help="Transport mode (default: sse)", + ) + parser.add_argument( + "--host", + default=app_settings.MCP_HOST, + help=f"Host to bind to (default: {app_settings.MCP_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=app_settings.MCP_PORT, + help=f"Port to listen on (default: {app_settings.MCP_PORT})", + ) + + def handle(self, *args, **options): + try: + from mcp.server.fastmcp import FastMCP + except ImportError: + raise CommandError( + "The 'mcp' package is required. Install it with: pip install 'bambu-run[mcp]'" + ) + + from asgiref.sync import sync_to_async + from bambu_run.conf import app_settings + from bambu_run import mcp_tools + + transport = options["transport"] + host = options["host"] + port = options["port"] + + mcp = FastMCP( + "Bambu-Run", + instructions=( + "Bambu-Run MCP server provides read-only access to 3D printer data " + "including live printer status, filament inventory, print history, " + "temperature trends, and diagnostics. All data comes from Bambu Lab " + "printers monitored via MQTT." + ), + ) + + # ── Register Tools ─────────────────────────────────────────────── + + @mcp.tool() + async def get_printer_status(printer_id: int | None = None) -> str: + """Get current live status of printer(s) including temperatures, progress, AMS slots, and errors. + + Args: + printer_id: Optional printer ID to filter. Omit for all printers. + """ + return await sync_to_async(mcp_tools.get_printer_status)(printer_id=printer_id) + + @mcp.tool() + async def list_printers() -> str: + """List all registered printers with their model, serial, IP, and active status.""" + return await sync_to_async(mcp_tools.list_printers)() + + @mcp.tool() + async def get_print_history( + status: str | None = None, + days: int | None = None, + project_name: str | None = None, + limit: int = 20, + ) -> str: + """Get print job history with optional filters. + + Args: + status: Filter by status (FINISH, FAILED, CANCELLED). + days: Only show jobs from the last N days. + project_name: Filter by project name (partial match). + limit: Maximum number of results (default 20). + """ + return await sync_to_async(mcp_tools.get_print_history)( + status=status, days=days, project_name=project_name, limit=limit + ) + + @mcp.tool() + async def get_print_job_detail(job_id: int) -> str: + """Get detailed information about a single print job including filament usage. + + Args: + job_id: The print job ID. + """ + return await sync_to_async(mcp_tools.get_print_job_detail)(job_id=job_id) + + @mcp.tool() + async def list_filaments( + type: str | None = None, + brand: str | None = None, + color: str | None = None, + loaded_in_ams: bool | None = None, + low_filament: bool | None = None, + ) -> str: + """List filament inventory with optional filters. + + Args: + type: Filter by material type (PLA, PETG, ABS, etc.). + brand: Filter by brand name (partial match). + color: Filter by color name (partial match). + loaded_in_ams: Filter by whether spool is currently in AMS. + low_filament: If true, only show spools with <=20% remaining. + """ + return await sync_to_async(mcp_tools.list_filaments)( + type=type, brand=brand, color=color, + loaded_in_ams=loaded_in_ams, low_filament=low_filament, + ) + + @mcp.tool() + async def get_filament_detail(filament_id: int) -> str: + """Get detailed information about a single filament spool including usage history. + + Args: + filament_id: The filament spool ID. + """ + return await sync_to_async(mcp_tools.get_filament_detail)(filament_id=filament_id) + + @mcp.tool() + async def get_temperature_history( + printer_id: int | None = None, + hours: int = 6, + metric: str = "all", + ) -> str: + """Get temperature trends (avg/min/max) over recent hours. + + Args: + printer_id: Optional printer ID to filter. + hours: Number of hours to look back (default 6). + metric: Which sensor to show: 'all', 'nozzle', 'bed', or 'chamber'. + """ + return await sync_to_async(mcp_tools.get_temperature_history)( + printer_id=printer_id, hours=hours, metric=metric + ) + + @mcp.tool() + async def get_filament_usage_stats(days: int = 30, group_by: str = "type") -> str: + """Get aggregate filament consumption statistics. + + Args: + days: Number of days to look back (default 30). + group_by: Group results by 'type', 'color', or 'spool'. + """ + return await sync_to_async(mcp_tools.get_filament_usage_stats)(days=days, group_by=group_by) + + @mcp.tool() + async def get_printer_health(printer_id: int | None = None) -> str: + """Get printer diagnostics including errors, humidity, WiFi signal, and recent failures. + + Args: + printer_id: Optional printer ID to filter. Omit for all printers. + """ + return await sync_to_async(mcp_tools.get_printer_health)(printer_id=printer_id) + + @mcp.tool() + async def search_print_jobs(query: str) -> str: + """Search print jobs by project name or gcode filename. + + Args: + query: Search text (partial match on project name or gcode file). + """ + return await sync_to_async(mcp_tools.search_print_jobs)(query=query) + + @mcp.tool() + async def get_printing_summary(days: int = 7) -> str: + """Get high-level printing activity summary including job counts, success rate, and top projects. + + Args: + days: Number of days to summarize (default 7). + """ + return await sync_to_async(mcp_tools.get_printing_summary)(days=days) + + @mcp.tool() + async def find_compatible_filament( + type: str, + min_remaining_percent: int = 10, + color: str | None = None, + ) -> str: + """Find filament spools matching material type and optional criteria. + + Args: + type: Material type to search for (PLA, PETG, ABS, etc.). + min_remaining_percent: Minimum remaining percentage (default 10). + color: Optional color filter (partial match). + """ + return await sync_to_async(mcp_tools.find_compatible_filament)( + type=type, min_remaining_percent=min_remaining_percent, color=color + ) + + # ── Register Resources ─────────────────────────────────────────── + + @mcp.resource("bambu://printers") + async def res_printers() -> str: + """List all registered printers.""" + return await sync_to_async(mcp_tools.resource_printers)() + + @mcp.resource("bambu://printers/{printer_id}/status") + async def res_printer_status(printer_id: int) -> str: + """Get latest status for a specific printer.""" + return await sync_to_async(mcp_tools.resource_printer_status)(printer_id) + + @mcp.resource("bambu://filaments") + async def res_filaments() -> str: + """Full filament inventory.""" + return await sync_to_async(mcp_tools.resource_filaments)() + + @mcp.resource("bambu://filaments/{filament_id}") + async def res_filament_detail(filament_id: int) -> str: + """Single filament spool with usage history.""" + return await sync_to_async(mcp_tools.resource_filament_detail)(filament_id) + + @mcp.resource("bambu://print-jobs/recent") + async def res_recent_jobs() -> str: + """Last 20 print jobs.""" + return await sync_to_async(mcp_tools.resource_recent_print_jobs)() + + @mcp.resource("bambu://filament-types") + async def res_filament_types() -> str: + """Filament type registry.""" + return await sync_to_async(mcp_tools.resource_filament_types)() + + @mcp.resource("bambu://filament-colors") + async def res_filament_colors() -> str: + """Filament color database.""" + return await sync_to_async(mcp_tools.resource_filament_colors)() + + # ── Register Prompts ───────────────────────────────────────────── + + @mcp.prompt() + async def printer_check_in(printer_id: int | None = None) -> str: + """Full printer status briefing with health check and recent prints. + + Args: + printer_id: Optional printer ID. Omit for all printers. + """ + return await sync_to_async(mcp_tools.prompt_printer_check_in)(printer_id=printer_id) + + @mcp.prompt() + async def filament_inventory_report() -> str: + """Comprehensive filament inventory report with low-stock warnings.""" + return await sync_to_async(mcp_tools.prompt_filament_inventory_report)() + + @mcp.prompt() + async def print_job_review(job_id: int) -> str: + """Detailed review of a completed print job. + + Args: + job_id: The print job ID to review. + """ + return await sync_to_async(mcp_tools.prompt_print_job_review)(job_id) + + @mcp.prompt() + async def weekly_printing_digest() -> str: + """Weekly printing activity summary with filament usage breakdown.""" + return await sync_to_async(mcp_tools.prompt_weekly_digest)() + + @mcp.prompt() + async def troubleshoot_printer(printer_id: int | None = None) -> str: + """Diagnose printer issues using recent health data, status, and temperatures. + + Args: + printer_id: Optional printer ID. Omit for all printers. + """ + return await sync_to_async(mcp_tools.prompt_troubleshoot_printer)(printer_id=printer_id) + + # ── Auth middleware for SSE ─────────────────────────────────────── + + api_key = app_settings.MCP_API_KEY + auth_backend = app_settings.MCP_AUTH_BACKEND + + if api_key or auth_backend: + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.responses import JSONResponse + + class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + # Custom auth backend takes priority + if auth_backend: + if not auth_backend(request): + return JSONResponse( + {"error": "Unauthorized"}, status_code=401 + ) + return await call_next(request) + + # API key auth + if api_key: + auth_header = request.headers.get("Authorization", "") + if auth_header == f"Bearer {api_key}": + return await call_next(request) + return JSONResponse( + {"error": "Invalid or missing API key"}, status_code=401 + ) + + return await call_next(request) + + # Attach middleware — FastMCP's SSE app is a Starlette app + original_sse_app = mcp.sse_app + + def patched_sse_app(): + app = original_sse_app() + app.add_middleware(AuthMiddleware) + return app + + mcp.sse_app = patched_sse_app + + # ── Run ────────────────────────────────────────────────────────── + + if transport == "sse": + try: + import uvicorn + except ImportError: + raise CommandError( + "uvicorn is required for SSE transport. Install it with: pip install uvicorn" + ) + + self.stdout.write( + self.style.SUCCESS( + f"Starting Bambu-Run MCP server (SSE) on {host}:{port}" + ) + ) + self.stdout.write( + f"Connect with: http://{host}:{port}/sse" + ) + app = mcp.sse_app() + uvicorn.run(app, host=host, port=port) + else: + self.stdout.write( + self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)") + ) + mcp.run(transport="stdio") diff --git a/bambu_run/management/commands/bambu_sync_cloud.py b/bambu_run/management/commands/bambu_sync_cloud.py new file mode 100644 index 0000000..738da6b --- /dev/null +++ b/bambu_run/management/commands/bambu_sync_cloud.py @@ -0,0 +1,140 @@ +""" +Management command: bambu_sync_cloud + +Backfill BambuCloudTask records from the Bambu Cloud API and link them to +existing PrintJob records. Primarily useful for jobs created before this +feature existed, or for re-syncing if the collector was offline at job end. + +Usage: + python manage.py bambu_sync_cloud + python manage.py bambu_sync_cloud --limit 100 + python manage.py bambu_sync_cloud --dry-run +""" + +import logging +import os + +from django.core.management.base import BaseCommand, CommandError + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Backfill BambuCloudTask records from Bambu Cloud API and link to PrintJob" + + def add_arguments(self, parser): + parser.add_argument( + '--limit', type=int, default=20, + help='Number of recent cloud tasks to fetch (default: 20)' + ) + parser.add_argument( + '--dry-run', action='store_true', + help='Show what would be synced without writing to DB' + ) + + def handle(self, *args, **options): + limit = options['limit'] + dry_run = options['dry_run'] + + bambu_token = os.environ.get('BAMBU_TOKEN') + bambu_username = os.environ.get('BAMBU_USERNAME') + bambu_password = os.environ.get('BAMBU_PASSWORD') + + if not bambu_token and not all([bambu_username, bambu_password]): + raise CommandError( + "Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD must be set" + ) + + try: + from bambulab import BambuClient + from bambulab.auth import BambuAuthenticator + except ImportError: + raise CommandError("bambu-lab-cloud-api is not installed") + + if bambu_token: + client = BambuClient(token=bambu_token) + else: + auth = BambuAuthenticator() + token = auth.login(bambu_username, bambu_password) + client = BambuClient(token=token) + + from bambu_run.bambu_cloud import get_tasks, upsert_cloud_task + from bambu_run.models import PrintJob + + self.stdout.write(f"Fetching last {limit} tasks from Bambu Cloud...") + try: + response = get_tasks(client, limit=limit) + except Exception as e: + raise CommandError(f"Cloud API request failed: {e}") + + hits = response.get('hits', response.get('tasks', [])) + self.stdout.write(f"Got {len(hits)} tasks from cloud") + + created_count = updated_count = linked_count = 0 + + for task_dict in hits: + task_id = task_dict.get('id') + design_title = task_dict.get('designTitle') or '' + plate_title = task_dict.get('title') or '' + display_name = design_title or plate_title or f"task-{task_id}" + + if dry_run: + self.stdout.write( + f" [dry-run] Would upsert task {task_id}: {display_name!r}" + ) + # Check if we'd link to a PrintJob + job = PrintJob.objects.filter(cloud_task_id_raw=task_id).first() + if job: + self.stdout.write(f" → would link to PrintJob #{job.id}") + continue + + try: + cloud_task, created = upsert_cloud_task(task_dict) + if created: + created_count += 1 + self.stdout.write(f" Created: {display_name!r} (task {task_id})") + else: + updated_count += 1 + + # Link to any matching PrintJob by cloud_task_id_raw + linked = PrintJob.objects.filter( + cloud_task_id_raw=task_id, cloud_task__isnull=True + ).update(cloud_task=cloud_task) + if linked: + linked_count += linked + self.stdout.write(f" Linked {linked} PrintJob(s) for task {task_id}") + + # Historical backfill: match by cloud start_time ± 2 min + device serial + if cloud_task.cloud_start_time and cloud_task.device_serial: + from datetime import timedelta + from bambu_run.models import Printer + printer = Printer.objects.filter( + serial_number=cloud_task.device_serial + ).first() + if printer: + window_start = cloud_task.cloud_start_time - timedelta(minutes=5) + window_end = cloud_task.cloud_start_time + timedelta(minutes=5) + historical = PrintJob.objects.filter( + device=printer, + start_time__gte=window_start, + start_time__lte=window_end, + cloud_task__isnull=True, + ).update(cloud_task=cloud_task) + if historical: + linked_count += historical + self.stdout.write( + f" Historically linked {historical} PrintJob(s) by time for task {task_id}" + ) + + except Exception as e: + self.stderr.write(f" Error processing task {task_id}: {e}") + + if not dry_run: + self.stdout.write( + self.style.SUCCESS( + f"\nDone: {created_count} created, {updated_count} updated, " + f"{linked_count} PrintJob(s) linked" + ) + ) + else: + self.stdout.write(self.style.WARNING("\nDry run complete — no changes written")) diff --git a/bambu_run/mcp_tools.py b/bambu_run/mcp_tools.py new file mode 100644 index 0000000..fe26e15 --- /dev/null +++ b/bambu_run/mcp_tools.py @@ -0,0 +1,728 @@ +""" +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 zoneinfo import ZoneInfo + +from django.db.models import Avg, Count, Max, Min, Q, Sum +from django.utils import timezone + +from .conf import app_settings + + +def _local_dt(dt, fmt="%Y-%m-%d %H:%M %Z"): + """Convert a UTC-aware datetime to the configured local timezone for display.""" + if dt is None: + return "—" + tz = ZoneInfo(app_settings.TIMEZONE) + return dt.astimezone(tz).strftime(fmt) + + +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 _job_name(job): + """Return the best available display name for a print job. + + Prefers cloud design_title (e.g., 'Planetary Gears Finger Fidget Spinners') + over the MQTT subtask_name (e.g., 'All variants at 0.16mm high quality'). + Falls back to project_name for local/SD prints with no cloud task. + """ + if job.cloud_task_id and job.cloud_task and job.cloud_task.design_title: + return job.cloud_task.design_title + return job.project_name + + +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: {_local_dt(metric.timestamp, '%Y-%m-%d %H:%M:%S %Z')}*") + 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", "cloud_task") + + 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( + Q(project_name__icontains=project_name) + | Q(cloud_task__design_title__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} | {_job_name(j)} | {j.device.name} | " + f"{j.final_status or 'In Progress'} | {j.completion_percent}% | " + f"{_format_duration(j.duration_minutes)} | " + f"{_local_dt(j.start_time, '%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", "cloud_task").get(id=job_id) + except PrintJob.DoesNotExist: + return f"Print job #{job_id} not found." + + lines = [f"# Print Job: {_job_name(job)}", ""] + if job.cloud_task and job.cloud_task.design_title and job.cloud_task.design_title != job.project_name: + lines.append(f"**Plate**: {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**: {_local_dt(job.start_time, '%Y-%m-%d %H:%M:%S %Z')}") + if job.end_time: + lines.append(f"**Ended**: {_local_dt(job.end_time, '%Y-%m-%d %H:%M:%S %Z')}") + 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 = _local_dt(f.last_used, "%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"{_local_dt(u.print_job.start_time, '%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: {_local_dt(latest.timestamp, '%Y-%m-%d %H:%M:%S %Z')}") + 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.select_related("cloud_task")[:5]: + parts.append(f"- {_job_name(job)} ({job.final_status}) — {_local_dt(job.start_time, '%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", "cloud_task").filter( + Q(project_name__icontains=query) + | Q(gcode_file__icontains=query) + | Q(cloud_task__design_title__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} | {_job_name(j)} | {j.device.name} | " + f"{j.final_status or 'In Progress'} | {_local_dt(j.start_time, '%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/bambu_run/migrations/0003_cloud_task.py b/bambu_run/migrations/0003_cloud_task.py new file mode 100644 index 0000000..9546c12 --- /dev/null +++ b/bambu_run/migrations/0003_cloud_task.py @@ -0,0 +1,177 @@ +# Generated by Django 6.0.2 on 2026-03-29 11:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bambu_run", "0002_filament_is_transparent"), + ] + + operations = [ + migrations.AddField( + model_name="printjob", + name="cloud_task_id_raw", + field=models.BigIntegerField( + blank=True, + db_index=True, + help_text="MQTT task_id — captured at job start, used to link cloud task", + null=True, + ), + ), + migrations.CreateModel( + name="BambuCloudTask", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "task_id", + models.BigIntegerField( + db_index=True, + help_text="Bambu Cloud task ID (matches MQTT task_id)", + unique=True, + ), + ), + ( + "design_id", + models.IntegerField( + blank=True, help_text="Makerworld design ID", null=True + ), + ), + ( + "design_title", + models.CharField( + blank=True, + help_text="Human project name from Makerworld (designTitle)", + max_length=500, + ), + ), + ( + "plate_title", + models.CharField( + blank=True, + help_text="Plate/variant name (matches MQTT subtask_name)", + max_length=500, + ), + ), + ("model_id", models.CharField(blank=True, max_length=100)), + ( + "profile_id", + models.BigIntegerField( + blank=True, help_text="Bambu Cloud profile ID", null=True + ), + ), + ("plate_index", models.SmallIntegerField(blank=True, null=True)), + ( + "device_serial", + models.CharField( + blank=True, + help_text="Printer serial number from cloud", + max_length=100, + ), + ), + ( + "cover_url", + models.URLField( + blank=True, + help_text="Plate preview image URL from S3", + max_length=1000, + ), + ), + ( + "weight_grams", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Actual filament weight reported by cloud", + max_digits=8, + null=True, + ), + ), + ( + "length_mm", + models.IntegerField( + blank=True, help_text="Filament length in mm", null=True + ), + ), + ( + "cost_time_seconds", + models.IntegerField( + blank=True, + help_text="Cloud-measured print duration in seconds", + null=True, + ), + ), + ( + "cloud_status", + models.SmallIntegerField( + blank=True, help_text="2=finish, 3=failed", null=True + ), + ), + ("bed_type", models.CharField(blank=True, max_length=50)), + ("use_ams", models.BooleanField(default=True)), + ( + "print_mode", + models.CharField( + blank=True, help_text="cloud_file, local, etc.", max_length=50 + ), + ), + ( + "ams_detail_mapping", + models.JSONField( + default=list, + help_text="Per-slot filament weight breakdown from cloud", + ), + ), + ("cloud_start_time", models.DateTimeField(blank=True, null=True)), + ("cloud_end_time", models.DateTimeField(blank=True, null=True)), + ( + "raw_data", + models.JSONField( + default=dict, + help_text="Full task response — preserved for future use", + ), + ), + ("synced_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Bambu Cloud Task", + "verbose_name_plural": "Bambu Cloud Tasks", + "db_table": "infrastructure_cloud_task", + "ordering": ["-cloud_start_time"], + "indexes": [ + models.Index( + fields=["task_id"], name="infrastruct_task_id_95b5ab_idx" + ), + models.Index( + fields=["design_id"], name="infrastruct_design__88bdc0_idx" + ), + models.Index( + fields=["-cloud_start_time"], + name="infrastruct_cloud_s_4078b0_idx", + ), + ], + }, + ), + migrations.AddField( + model_name="printjob", + name="cloud_task", + field=models.ForeignKey( + blank=True, + help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="print_jobs", + to="bambu_run.bambucloudtask", + ), + ), + ] diff --git a/bambu_run/models.py b/bambu_run/models.py index 38f6b9e..e6d8f26 100644 --- a/bambu_run/models.py +++ b/bambu_run/models.py @@ -492,6 +492,47 @@ class FilamentSnapshot(models.Model): return f"Tray {self.tray_id}: {filament_info}" +class BambuCloudTask(models.Model): + """Cloud task record synced from Bambu Cloud API (v1/user-service/my/tasks).""" + + task_id = models.BigIntegerField(unique=True, db_index=True, help_text="Bambu Cloud task ID (matches MQTT task_id)") + design_id = models.IntegerField(null=True, blank=True, help_text="Makerworld design ID") + design_title = models.CharField(max_length=500, blank=True, help_text="Human project name from Makerworld (designTitle)") + plate_title = models.CharField(max_length=500, blank=True, help_text="Plate/variant name (matches MQTT subtask_name)") + model_id = models.CharField(max_length=100, blank=True) + profile_id = models.BigIntegerField(null=True, blank=True, help_text="Bambu Cloud profile ID") + plate_index = models.SmallIntegerField(null=True, blank=True) + device_serial = models.CharField(max_length=100, blank=True, help_text="Printer serial number from cloud") + cover_url = models.URLField(max_length=1000, blank=True, help_text="Plate preview image URL from S3") + weight_grams = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="Actual filament weight reported by cloud") + length_mm = models.IntegerField(null=True, blank=True, help_text="Filament length in mm") + cost_time_seconds = models.IntegerField(null=True, blank=True, help_text="Cloud-measured print duration in seconds") + cloud_status = models.SmallIntegerField(null=True, blank=True, help_text="2=finish, 3=failed") + bed_type = models.CharField(max_length=50, blank=True) + use_ams = models.BooleanField(default=True) + print_mode = models.CharField(max_length=50, blank=True, help_text="cloud_file, local, etc.") + ams_detail_mapping = models.JSONField(default=list, help_text="Per-slot filament weight breakdown from cloud") + cloud_start_time = models.DateTimeField(null=True, blank=True) + cloud_end_time = models.DateTimeField(null=True, blank=True) + raw_data = models.JSONField(default=dict, help_text="Full task response — preserved for future use") + synced_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "infrastructure_cloud_task" + verbose_name = "Bambu Cloud Task" + verbose_name_plural = "Bambu Cloud Tasks" + ordering = ["-cloud_start_time"] + indexes = [ + models.Index(fields=["task_id"]), + models.Index(fields=["design_id"]), + models.Index(fields=["-cloud_start_time"]), + ] + + def __str__(self): + name = self.design_title or self.plate_title or f"task-{self.task_id}" + return f"{name} ({self.cloud_start_time.strftime('%Y-%m-%d') if self.cloud_start_time else 'unknown date'})" + + class PrintJob(models.Model): """Represents a single print job from start to finish""" @@ -505,6 +546,16 @@ class PrintJob(models.Model): ) gcode_file = models.CharField(max_length=200, null=True, blank=True) + cloud_task = models.ForeignKey( + 'BambuCloudTask', on_delete=models.SET_NULL, + null=True, blank=True, related_name='print_jobs', + help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)" + ) + cloud_task_id_raw = models.BigIntegerField( + null=True, blank=True, db_index=True, + help_text="MQTT task_id — captured at job start, used to link cloud task" + ) + start_time = models.DateTimeField(help_text="When print started") end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed") duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration") @@ -544,6 +595,13 @@ class PrintJob(models.Model): status = self.final_status or 'In Progress' return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}" + @property + def display_name(self): + """Human-readable job name: cloud design_title if available, else project_name.""" + if self.cloud_task_id and self.cloud_task and self.cloud_task.design_title: + return self.cloud_task.design_title + return self.project_name + def calculate_duration(self): """Calculate print duration if end_time is set""" if self.end_time and self.start_time: diff --git a/bambu_run/mqtt_client.py b/bambu_run/mqtt_client.py index 25db77e..9c3aa70 100644 --- a/bambu_run/mqtt_client.py +++ b/bambu_run/mqtt_client.py @@ -482,6 +482,8 @@ class PrinterState: "print_line_number": self.print_line_number, "subtask_name": self.subtask_name, "gcode_file": self.gcode_file, + "task_id": self.task_id, + "project_id": self.project_id, "cooling_fan_speed": self.cooling_fan_speed, "heatbreak_fan_speed": self.heatbreak_fan_speed, "big_fan1_speed": self.big_fan1_speed, diff --git a/bambu_run/templates/bambu_run/filament_detail.html b/bambu_run/templates/bambu_run/filament_detail.html index aba3c62..b0a0679 100644 --- a/bambu_run/templates/bambu_run/filament_detail.html +++ b/bambu_run/templates/bambu_run/filament_detail.html @@ -154,7 +154,7 @@ {% for usage in print_usages %} - {{ usage.print_job.project_name }} + {{ usage.print_job.display_name }} {{ usage.print_job.start_time|date:"Y-m-d H:i" }} Tray {{ usage.tray_id }} {{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g) diff --git a/bambu_run/templates/bambu_run/printer_dashboard.html b/bambu_run/templates/bambu_run/printer_dashboard.html index 49ae1cc..58eeb95 100644 --- a/bambu_run/templates/bambu_run/printer_dashboard.html +++ b/bambu_run/templates/bambu_run/printer_dashboard.html @@ -94,7 +94,7 @@
- Job Name: {{ stats.subtask_name }} + Job Name: {{ stats.job_display_name }}
State: {{ stats.gcode_state }} diff --git a/bambu_run/views.py b/bambu_run/views.py index 73d42a2..1ca6a41 100644 --- a/bambu_run/views.py +++ b/bambu_run/views.py @@ -130,6 +130,24 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): except Exception: filaments_list = [] + subtask_name = latest_metric.subtask_name or "No active print" + # Look up active PrintJob for a better display name (cloud design_title) + job_display_name = subtask_name + if latest_metric.subtask_name: + active_job = ( + PrintJob.objects.filter( + device=printer_device, + project_name=latest_metric.subtask_name, + end_time__isnull=True, + ).select_related('cloud_task').first() + or PrintJob.objects.filter( + device=printer_device, + project_name=latest_metric.subtask_name, + ).select_related('cloud_task').order_by('-start_time').first() + ) + if active_job: + job_display_name = active_job.display_name + stats = { "nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0, "bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0, @@ -137,7 +155,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): "print_percent": latest_metric.print_percent or 0, "gcode_state": latest_metric.gcode_state or "Unknown", "print_type": latest_metric.print_type or "idle", - "subtask_name": latest_metric.subtask_name or "No active print", + "subtask_name": subtask_name, + "job_display_name": job_display_name, "chamber_light": latest_metric.chamber_light or "unknown", "ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None, "ams_humidity": latest_metric.ams_humidity, @@ -158,7 +177,24 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): return context def _calculate_project_markers(self, metrics, timezone_info): - """Calculate where print jobs start and end""" + """Calculate where print jobs start and end, using cloud design_title when available.""" + if not metrics: + return [] + + # Build a lookup: subtask_name -> display_name from PrintJobs in this time window + window_start = metrics[0].timestamp + window_end = metrics[-1].timestamp + device = metrics[0].device + jobs_qs = PrintJob.objects.filter( + device=device, + start_time__gte=window_start - timedelta(minutes=5), + start_time__lte=window_end + timedelta(minutes=5), + ).select_related('cloud_task') + # Map project_name (= subtask_name) -> best display name + subtask_to_display = {} + for job in jobs_qs: + subtask_to_display[job.project_name] = job.display_name + markers = [] current_job = None last_state = None @@ -170,21 +206,23 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): is_printing = gcode_state not in ['FINISH', 'IDLE', None, ''] if subtask and subtask != current_job and is_printing: + display = subtask_to_display.get(subtask, subtask) markers.append({ 'type': 'start', 'index': idx, 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), - 'project_name': subtask, + 'project_name': display, }) current_job = subtask last_state = gcode_state elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']: + display = subtask_to_display.get(current_job, current_job) markers.append({ 'type': 'end', 'index': idx, 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), - 'project_name': current_job, + 'project_name': display, }) current_job = None @@ -613,7 +651,7 @@ class FilamentDetailView(LoginRequiredMixin, DetailView): context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE filament = self.object - context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20] + context['print_usages'] = filament.print_usages.select_related('print_job__cloud_task').order_by('-print_job__start_time')[:20] total_consumed = filament.print_usages.aggregate( total=Sum('consumed_percent') 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..58c6d00 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -25,6 +25,19 @@ 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 +priority=10 + [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 a7c3d01..7883076 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