From 9978b7027a9fea729daa936cb3285b15486f0be2 Mon Sep 17 00:00:00 2001 From: RNL Date: Sun, 29 Mar 2026 22:48:02 +1100 Subject: [PATCH] added bambu cloud task sync with correct endpoint other than py cloud api --- bambu_run/admin.py | 20 +- bambu_run/bambu_cloud.py | 121 ++++++++++++ bambu_run/conf.py | 9 + .../management/commands/bambu_collector.py | 10 +- .../management/commands/bambu_sync_cloud.py | 140 ++++++++++++++ bambu_run/mcp_tools.py | 39 +++- bambu_run/migrations/0003_cloud_task.py | 177 ++++++++++++++++++ bambu_run/models.py | 51 +++++ bambu_run/mqtt_client.py | 2 + 9 files changed, 557 insertions(+), 12 deletions(-) create mode 100644 bambu_run/bambu_cloud.py create mode 100644 bambu_run/management/commands/bambu_sync_cloud.py create mode 100644 bambu_run/migrations/0003_cloud_task.py 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 a67c0d5..dd4a020 100644 --- a/bambu_run/conf.py +++ b/bambu_run/conf.py @@ -72,5 +72,14 @@ class _Settings: 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_sync_cloud.py b/bambu_run/management/commands/bambu_sync_cloud.py new file mode 100644 index 0000000..a8186f8 --- /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=2) + window_end = cloud_task.cloud_start_time + timedelta(minutes=2) + 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 index e913276..fe26e15 100644 --- a/bambu_run/mcp_tools.py +++ b/bambu_run/mcp_tools.py @@ -30,6 +30,18 @@ def _redact(value, label="[redacted]"): 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: @@ -148,7 +160,7 @@ 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") + qs = PrintJob.objects.select_related("device", "cloud_task") if status: qs = qs.filter(final_status__iexact=status) @@ -156,7 +168,10 @@ def get_print_history(status=None, days=None, project_name=None, limit=20): 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) + qs = qs.filter( + Q(project_name__icontains=project_name) + | Q(cloud_task__design_title__icontains=project_name) + ) jobs = qs[:int(limit)] if not jobs: @@ -167,7 +182,7 @@ def get_print_history(status=None, days=None, project_name=None, limit=20): lines.append("|----|---------|---------|--------|----------|----------|---------|") for j in jobs: lines.append( - f"| {j.id} | {j.project_name} | {j.device.name} | " + 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')} |" @@ -180,11 +195,13 @@ def get_print_job_detail(job_id): from .models import FilamentUsage, PrintJob try: - job = PrintJob.objects.select_related("device").get(id=job_id) + 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.project_name}", ""] + 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}%") @@ -469,8 +486,8 @@ def get_printer_health(printer_id=None): ) 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}) — {_local_dt(job.start_time, '%m-%d %H:%M')}") + 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) @@ -491,8 +508,10 @@ def search_print_jobs(query): 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) + 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: @@ -504,7 +523,7 @@ def search_print_jobs(query): lines.append("|----|---------|---------|--------|------|") for j in jobs: lines.append( - f"| {j.id} | {j.project_name} | {j.device.name} | " + 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) 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..9cf763d 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") 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,