added bambu cloud task sync with correct endpoint other than py cloud api

This commit is contained in:
RNL
2026-03-29 22:48:02 +11:00
parent e551dcc5fd
commit 9978b7027a
9 changed files with 557 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin 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) @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_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
list_filter = ('is_primary', 'tray_id') list_filter = ('is_primary', 'tray_id')
readonly_fields = ('consumed_percent', 'consumed_grams') 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',)}),
)

121
bambu_run/bambu_cloud.py Normal file
View File

@@ -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}")

View File

@@ -72,5 +72,14 @@ class _Settings:
def MCP_HIDE_SENSITIVE(self): def MCP_HIDE_SENSITIVE(self):
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False) 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() app_settings = _Settings()

View File

@@ -473,6 +473,7 @@ class Command(BaseCommand):
if self.current_print_job: if self.current_print_job:
self._finalize_print_job(metric, snapshot) self._finalize_print_job(metric, snapshot)
raw_task_id = snapshot.get('task_id')
self.current_print_job = PrintJob.objects.create( self.current_print_job = PrintJob.objects.create(
device=self.printer_device, device=self.printer_device,
project_name=subtask_name, project_name=subtask_name,
@@ -480,7 +481,8 @@ class Command(BaseCommand):
start_time=metric.timestamp, start_time=metric.timestamp,
start_metric=metric, start_metric=metric,
total_layers=snapshot.get('total_layer_num'), 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() self.trays_used = set()
logger.info(f"Print job started: {subtask_name}") 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.calculate_duration()
self.current_print_job.save() 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 start_metric = self.current_print_job.start_metric
if not start_metric: if not start_metric:
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage") logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")

View File

@@ -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"))

View File

@@ -30,6 +30,18 @@ def _redact(value, label="[redacted]"):
return value 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): def _format_duration(minutes):
"""Format minutes into human-readable duration.""" """Format minutes into human-readable duration."""
if minutes is None: 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.""" """Print job history with optional filters."""
from .models import PrintJob from .models import PrintJob
qs = PrintJob.objects.select_related("device") qs = PrintJob.objects.select_related("device", "cloud_task")
if status: if status:
qs = qs.filter(final_status__iexact=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)) cutoff = timezone.now() - timedelta(days=int(days))
qs = qs.filter(start_time__gte=cutoff) qs = qs.filter(start_time__gte=cutoff)
if project_name: 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)] jobs = qs[:int(limit)]
if not jobs: if not jobs:
@@ -167,7 +182,7 @@ def get_print_history(status=None, days=None, project_name=None, limit=20):
lines.append("|----|---------|---------|--------|----------|----------|---------|") lines.append("|----|---------|---------|--------|----------|----------|---------|")
for j in jobs: for j in jobs:
lines.append( 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"{j.final_status or 'In Progress'} | {j.completion_percent}% | "
f"{_format_duration(j.duration_minutes)} | " f"{_format_duration(j.duration_minutes)} | "
f"{_local_dt(j.start_time, '%Y-%m-%d %H:%M')} |" 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 from .models import FilamentUsage, PrintJob
try: 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: except PrintJob.DoesNotExist:
return f"Print job #{job_id} not found." 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"**Printer**: {job.device.name}")
lines.append(f"**Status**: {job.final_status or 'In Progress'}") lines.append(f"**Status**: {job.final_status or 'In Progress'}")
lines.append(f"**Progress**: {job.completion_percent}%") lines.append(f"**Progress**: {job.completion_percent}%")
@@ -469,8 +486,8 @@ def get_printer_health(printer_id=None):
) )
if failed.exists(): if failed.exists():
parts.append(f"### Recent Failures (7d): {failed.count()}") parts.append(f"### Recent Failures (7d): {failed.count()}")
for job in failed[:5]: for job in failed.select_related("cloud_task")[:5]:
parts.append(f"- {job.project_name} ({job.final_status}) — {_local_dt(job.start_time, '%m-%d %H:%M')}") parts.append(f"- {_job_name(job)} ({job.final_status}) — {_local_dt(job.start_time, '%m-%d %H:%M')}")
# Success rate # Success rate
week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago) week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago)
@@ -491,8 +508,10 @@ def search_print_jobs(query):
if not query: if not query:
return "Please provide a search query." return "Please provide a search query."
jobs = PrintJob.objects.select_related("device").filter( jobs = PrintJob.objects.select_related("device", "cloud_task").filter(
Q(project_name__icontains=query) | Q(gcode_file__icontains=query) Q(project_name__icontains=query)
| Q(gcode_file__icontains=query)
| Q(cloud_task__design_title__icontains=query)
)[:20] )[:20]
if not jobs: if not jobs:
@@ -504,7 +523,7 @@ def search_print_jobs(query):
lines.append("|----|---------|---------|--------|------|") lines.append("|----|---------|---------|--------|------|")
for j in jobs: for j in jobs:
lines.append( 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')} |" f"{j.final_status or 'In Progress'} | {_local_dt(j.start_time, '%Y-%m-%d')} |"
) )
return "\n".join(lines) return "\n".join(lines)

View File

@@ -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",
),
),
]

View File

@@ -492,6 +492,47 @@ class FilamentSnapshot(models.Model):
return f"Tray {self.tray_id}: {filament_info}" 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): class PrintJob(models.Model):
"""Represents a single print job from start to finish""" """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) 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") start_time = models.DateTimeField(help_text="When print started")
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed") 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") duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")

View File

@@ -482,6 +482,8 @@ class PrinterState:
"print_line_number": self.print_line_number, "print_line_number": self.print_line_number,
"subtask_name": self.subtask_name, "subtask_name": self.subtask_name,
"gcode_file": self.gcode_file, "gcode_file": self.gcode_file,
"task_id": self.task_id,
"project_id": self.project_id,
"cooling_fan_speed": self.cooling_fan_speed, "cooling_fan_speed": self.cooling_fan_speed,
"heatbreak_fan_speed": self.heatbreak_fan_speed, "heatbreak_fan_speed": self.heatbreak_fan_speed,
"big_fan1_speed": self.big_fan1_speed, "big_fan1_speed": self.big_fan1_speed,