mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
added bambu cloud task sync with correct endpoint other than py cloud api
This commit is contained in:
@@ -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',)}),
|
||||
)
|
||||
|
||||
121
bambu_run/bambu_cloud.py
Normal file
121
bambu_run/bambu_cloud.py
Normal 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}")
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
140
bambu_run/management/commands/bambu_sync_cloud.py
Normal file
140
bambu_run/management/commands/bambu_sync_cloud.py
Normal 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"))
|
||||
@@ -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)
|
||||
|
||||
177
bambu_run/migrations/0003_cloud_task.py
Normal file
177
bambu_run/migrations/0003_cloud_task.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user