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 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
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):
|
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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
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
|
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)
|
||||||
|
|||||||
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}"
|
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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user