mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
Compare commits
9 Commits
v0.1.3
...
34293ce81a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34293ce81a | ||
|
|
af845e3490 | ||
|
|
6aab42b03e | ||
|
|
61d1f630d3 | ||
|
|
c032745eb5 | ||
|
|
2af3509010 | ||
|
|
dd57a963ac | ||
|
|
6fadccb527 | ||
|
|
fa90ef11b6 |
@@ -20,3 +20,4 @@ BAMBU_PASSWORD=your_password
|
|||||||
# DEBUG=True
|
# DEBUG=True
|
||||||
# DJANGO_SECRET_KEY=change-me-to-a-random-string
|
# DJANGO_SECRET_KEY=change-me-to-a-random-string
|
||||||
# ALLOWED_HOSTS=localhost,127.0.0.1
|
# ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
# CSRF_TRUSTED_ORIGINS="https://bambu-run.example.com,http://bambu-run.example.com"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
|
|||||||
|
|
||||||
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir ".[standalone]"
|
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -40,5 +40,6 @@ RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
|
|||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
EXPOSE 8808
|
||||||
|
|
||||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -51,5 +51,35 @@ class _Settings:
|
|||||||
def AUTO_CREATE_BRAND(self):
|
def AUTO_CREATE_BRAND(self):
|
||||||
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||||
|
|
||||||
|
# MCP Server settings
|
||||||
|
@property
|
||||||
|
def MCP_API_KEY(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_API_KEY", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_HOST(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_HOST", "0.0.0.0")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_PORT(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_AUTH_BACKEND(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_HIDE_SENSITIVE(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False)
|
||||||
|
|
||||||
|
# Cloud sync settings
|
||||||
|
@property
|
||||||
|
def CLOUD_SYNC_ENABLED(self):
|
||||||
|
return get_setting("BAMBU_RUN_CLOUD_SYNC_ENABLED", True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CLOUD_SYNC_DAYS(self):
|
||||||
|
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
|
||||||
|
|
||||||
|
|
||||||
app_settings = _Settings()
|
app_settings = _Settings()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class FilamentForm(forms.ModelForm):
|
|||||||
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
|
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
|
||||||
'diameter', 'initial_weight_grams',
|
'diameter', 'initial_weight_grams',
|
||||||
'remaining_percent', 'remaining_weight_grams',
|
'remaining_percent', 'remaining_weight_grams',
|
||||||
'is_loaded_in_ams', 'current_tray_id',
|
'is_loaded_in_ams', 'current_tray_id', 'ams_unit_id', 'ams_type',
|
||||||
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
@@ -87,7 +87,15 @@ class FilamentForm(forms.ModelForm):
|
|||||||
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||||
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
|
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
|
||||||
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
|
'current_tray_id': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control', 'min': '0', 'max': '15',
|
||||||
|
'placeholder': '0–3 for AMS / AMS 2 Pro, 0 for AMS HT',
|
||||||
|
}),
|
||||||
|
'ams_unit_id': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control', 'min': '0', 'max': '255',
|
||||||
|
'placeholder': 'AMS unit id (0,1,… or 128 for AMS HT)',
|
||||||
|
}),
|
||||||
|
'ams_type': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||||
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||||
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
@@ -106,6 +114,8 @@ class FilamentForm(forms.ModelForm):
|
|||||||
self.fields['type'].required = False
|
self.fields['type'].required = False
|
||||||
self.fields['sub_type'].required = False
|
self.fields['sub_type'].required = False
|
||||||
self.fields['brand'].required = False
|
self.fields['brand'].required = False
|
||||||
|
self.fields['ams_unit_id'].required = False
|
||||||
|
self.fields['ams_type'].required = False
|
||||||
|
|
||||||
self._populate_color_choices()
|
self._populate_color_choices()
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if run_once:
|
if run_once:
|
||||||
|
import time as _time
|
||||||
|
_time.sleep(5)
|
||||||
self._collect_printer_data()
|
self._collect_printer_data()
|
||||||
logger.info("Single collection completed successfully")
|
logger.info("Single collection completed successfully")
|
||||||
else:
|
else:
|
||||||
@@ -122,6 +124,24 @@ class Command(BaseCommand):
|
|||||||
logger.exception(f"Fatal error in main loop: {e}")
|
logger.exception(f"Fatal error in main loop: {e}")
|
||||||
raise CommandError(f"Runner failed: {e}")
|
raise CommandError(f"Runner failed: {e}")
|
||||||
|
|
||||||
|
def _request_full_status_when_ready(self, timeout: float = 20.0) -> None:
|
||||||
|
"""Send pushall once the MQTT broker connection is confirmed.
|
||||||
|
|
||||||
|
BambuPrinter._connected is set True immediately after connect(blocking=False),
|
||||||
|
before the broker handshake. Poll MQTTClient.connected (set in _on_connect)
|
||||||
|
instead, so publish() won't raise "Not connected to broker".
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
deadline = _time.time() + timeout
|
||||||
|
while _time.time() < deadline:
|
||||||
|
mqtt_client = getattr(self.printer_client, "_mqtt", None)
|
||||||
|
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
|
||||||
|
self.printer_client._mqtt.request_full_status()
|
||||||
|
logger.info("Sent MQTT pushall request")
|
||||||
|
return
|
||||||
|
_time.sleep(0.5)
|
||||||
|
logger.warning("MQTT broker connection not confirmed within %.1fs; skipping pushall", timeout)
|
||||||
|
|
||||||
def _configure_logging(self):
|
def _configure_logging(self):
|
||||||
log_level = logging.DEBUG if self.verbose else logging.INFO
|
log_level = logging.DEBUG if self.verbose else logging.INFO
|
||||||
logger.setLevel(log_level)
|
logger.setLevel(log_level)
|
||||||
@@ -167,6 +187,11 @@ class Command(BaseCommand):
|
|||||||
logger.info("Initiating MQTT connection...")
|
logger.info("Initiating MQTT connection...")
|
||||||
self.printer_client.connect(blocking=False)
|
self.printer_client.connect(blocking=False)
|
||||||
logger.info("MQTT connection initiated (non-blocking)")
|
logger.info("MQTT connection initiated (non-blocking)")
|
||||||
|
# Request full status so AMS + dual-nozzle data arrive on startup.
|
||||||
|
try:
|
||||||
|
self._request_full_status_when_ready()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("pushall request skipped (non-fatal): %s", e)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
||||||
@@ -377,6 +402,8 @@ class Command(BaseCommand):
|
|||||||
created_by='Auto Detection',
|
created_by='Auto Detection',
|
||||||
is_loaded_in_ams=True,
|
is_loaded_in_ams=True,
|
||||||
current_tray_id=tray_data.get('tray_id'),
|
current_tray_id=tray_data.get('tray_id'),
|
||||||
|
ams_unit_id=tray_data.get('ams_unit_id'),
|
||||||
|
ams_type=tray_data.get('ams_type', '') or '',
|
||||||
last_loaded_date=timezone.now(),
|
last_loaded_date=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,9 +417,13 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
return filament
|
return filament
|
||||||
|
|
||||||
def _update_filament_status(self, filament, tray_id, remain_percent):
|
def _update_filament_status(self, filament, tray_id, remain_percent, tray_data=None):
|
||||||
from bambu_run.models import Filament
|
from bambu_run.models import Filament
|
||||||
|
|
||||||
|
tray_data = tray_data or {}
|
||||||
|
ams_unit_id = tray_data.get('ams_unit_id')
|
||||||
|
ams_type_label = tray_data.get('ams_type', '') or ''
|
||||||
|
|
||||||
if filament.remaining_percent != remain_percent:
|
if filament.remaining_percent != remain_percent:
|
||||||
filament.remaining_percent = remain_percent
|
filament.remaining_percent = remain_percent
|
||||||
filament.update_remaining_weight()
|
filament.update_remaining_weight()
|
||||||
@@ -400,10 +431,19 @@ class Command(BaseCommand):
|
|||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
||||||
|
|
||||||
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
|
location_changed = (
|
||||||
previous_filament = Filament.objects.filter(
|
not filament.is_loaded_in_ams
|
||||||
|
or filament.current_tray_id != tray_id
|
||||||
|
or (ams_unit_id is not None and filament.ams_unit_id != ams_unit_id)
|
||||||
|
)
|
||||||
|
if location_changed:
|
||||||
|
# Unload anything previously occupying THIS exact (unit, tray) slot.
|
||||||
|
unload_qs = Filament.objects.filter(
|
||||||
is_loaded_in_ams=True, current_tray_id=tray_id
|
is_loaded_in_ams=True, current_tray_id=tray_id
|
||||||
).exclude(id=filament.id).first()
|
).exclude(id=filament.id)
|
||||||
|
if ams_unit_id is not None:
|
||||||
|
unload_qs = unload_qs.filter(ams_unit_id=ams_unit_id)
|
||||||
|
previous_filament = unload_qs.first()
|
||||||
|
|
||||||
if previous_filament:
|
if previous_filament:
|
||||||
previous_filament.is_loaded_in_ams = False
|
previous_filament.is_loaded_in_ams = False
|
||||||
@@ -411,14 +451,21 @@ class Command(BaseCommand):
|
|||||||
previous_filament.save()
|
previous_filament.save()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
||||||
f"(replaced by {filament.brand} {filament.type} - {filament.color})"
|
f"(unit {ams_unit_id}; replaced by {filament.brand} {filament.type} - {filament.color})"
|
||||||
)
|
)
|
||||||
|
|
||||||
filament.is_loaded_in_ams = True
|
filament.is_loaded_in_ams = True
|
||||||
filament.current_tray_id = tray_id
|
filament.current_tray_id = tray_id
|
||||||
|
if ams_unit_id is not None:
|
||||||
|
filament.ams_unit_id = ams_unit_id
|
||||||
|
if ams_type_label:
|
||||||
|
filament.ams_type = ams_type_label
|
||||||
filament.last_loaded_date = timezone.now()
|
filament.last_loaded_date = timezone.now()
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(f"Updated filament location: Tray {tray_id}")
|
logger.debug(f"Updated filament location: unit={ams_unit_id} tray={tray_id}")
|
||||||
|
elif ams_type_label and filament.ams_type != ams_type_label:
|
||||||
|
# Same slot but ams_type was previously unknown — fill it in.
|
||||||
|
filament.ams_type = ams_type_label
|
||||||
|
|
||||||
filament.save()
|
filament.save()
|
||||||
|
|
||||||
@@ -439,10 +486,13 @@ class Command(BaseCommand):
|
|||||||
if filament:
|
if filament:
|
||||||
remain_percent = tray_data.get('remain_percent')
|
remain_percent = tray_data.get('remain_percent')
|
||||||
if remain_percent is not None:
|
if remain_percent is not None:
|
||||||
self._update_filament_status(filament, tray_id, remain_percent)
|
self._update_filament_status(filament, tray_id, remain_percent, tray_data)
|
||||||
|
|
||||||
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None
|
# Locate the AMS unit this tray belongs to. Use the unit_id supplied
|
||||||
unit_data = ams_units.get(unit_id, {})
|
# by the snapshot directly (matches MQTT ams[i].id, including 128 for AMS HT)
|
||||||
|
# — the legacy `tray_id // 4` math breaks for AMS HT.
|
||||||
|
unit_id_int = tray_data.get('ams_unit_id')
|
||||||
|
unit_data = ams_units.get(str(unit_id_int)) if unit_id_int is not None else {}
|
||||||
|
|
||||||
FilamentSnapshot.objects.create(
|
FilamentSnapshot.objects.create(
|
||||||
printer_metric=printer_metric,
|
printer_metric=printer_metric,
|
||||||
@@ -473,6 +523,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 +531,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 +572,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")
|
||||||
@@ -587,6 +645,10 @@ class Command(BaseCommand):
|
|||||||
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
||||||
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
||||||
nozzle_type=snapshot.get("nozzle_type"),
|
nozzle_type=snapshot.get("nozzle_type"),
|
||||||
|
nozzle_temp_left=self._to_decimal(snapshot.get("nozzle_temp_left")),
|
||||||
|
nozzle_target_temp_left=self._to_decimal(snapshot.get("nozzle_target_temp_left")),
|
||||||
|
nozzle_diameter_left=self._to_decimal(snapshot.get("nozzle_diameter_left")),
|
||||||
|
nozzle_type_left=snapshot.get("nozzle_type_left"),
|
||||||
gcode_state=snapshot.get("gcode_state"),
|
gcode_state=snapshot.get("gcode_state"),
|
||||||
print_type=snapshot.get("print_type"),
|
print_type=snapshot.get("print_type"),
|
||||||
print_percent=snapshot.get("print_percent"),
|
print_percent=snapshot.get("print_percent"),
|
||||||
|
|||||||
355
bambu_run/management/commands/bambu_mcp_server.py
Normal file
355
bambu_run/management/commands/bambu_mcp_server.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
"""
|
||||||
|
Management command to run the Bambu-Run MCP server.
|
||||||
|
|
||||||
|
Supports SSE (network) and stdio (local) transports.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py bambu_mcp_server
|
||||||
|
python manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
||||||
|
python manage.py bambu_mcp_server --transport stdio
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
logger = logging.getLogger("bambu_run.mcp")
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Run the Bambu-Run MCP server for AI agent access"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
from bambu_run.conf import app_settings
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--transport",
|
||||||
|
choices=["sse", "stdio"],
|
||||||
|
default="sse",
|
||||||
|
help="Transport mode (default: sse)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default=app_settings.MCP_HOST,
|
||||||
|
help=f"Host to bind to (default: {app_settings.MCP_HOST})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=app_settings.MCP_PORT,
|
||||||
|
help=f"Port to listen on (default: {app_settings.MCP_PORT})",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
except ImportError:
|
||||||
|
raise CommandError(
|
||||||
|
"The 'mcp' package is required. Install it with: pip install 'bambu-run[mcp]'"
|
||||||
|
)
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from bambu_run.conf import app_settings
|
||||||
|
from bambu_run import mcp_tools
|
||||||
|
|
||||||
|
transport = options["transport"]
|
||||||
|
host = options["host"]
|
||||||
|
port = options["port"]
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"Bambu-Run",
|
||||||
|
instructions=(
|
||||||
|
"Bambu-Run MCP server provides read-only access to 3D printer data "
|
||||||
|
"including live printer status, filament inventory, print history, "
|
||||||
|
"temperature trends, and diagnostics. All data comes from Bambu Lab "
|
||||||
|
"printers monitored via MQTT."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Register Tools ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_printer_status(printer_id: int | None = None) -> str:
|
||||||
|
"""Get current live status of printer(s) including temperatures, progress, AMS slots, and errors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
printer_id: Optional printer ID to filter. Omit for all printers.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_printer_status)(printer_id=printer_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_printers() -> str:
|
||||||
|
"""List all registered printers with their model, serial, IP, and active status."""
|
||||||
|
return await sync_to_async(mcp_tools.list_printers)()
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_print_history(
|
||||||
|
status: str | None = None,
|
||||||
|
days: int | None = None,
|
||||||
|
project_name: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> str:
|
||||||
|
"""Get print job history with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by status (FINISH, FAILED, CANCELLED).
|
||||||
|
days: Only show jobs from the last N days.
|
||||||
|
project_name: Filter by project name (partial match).
|
||||||
|
limit: Maximum number of results (default 20).
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_print_history)(
|
||||||
|
status=status, days=days, project_name=project_name, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_print_job_detail(job_id: int) -> str:
|
||||||
|
"""Get detailed information about a single print job including filament usage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: The print job ID.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_print_job_detail)(job_id=job_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_filaments(
|
||||||
|
type: str | None = None,
|
||||||
|
brand: str | None = None,
|
||||||
|
color: str | None = None,
|
||||||
|
loaded_in_ams: bool | None = None,
|
||||||
|
low_filament: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""List filament inventory with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
type: Filter by material type (PLA, PETG, ABS, etc.).
|
||||||
|
brand: Filter by brand name (partial match).
|
||||||
|
color: Filter by color name (partial match).
|
||||||
|
loaded_in_ams: Filter by whether spool is currently in AMS.
|
||||||
|
low_filament: If true, only show spools with <=20% remaining.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.list_filaments)(
|
||||||
|
type=type, brand=brand, color=color,
|
||||||
|
loaded_in_ams=loaded_in_ams, low_filament=low_filament,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_filament_detail(filament_id: int) -> str:
|
||||||
|
"""Get detailed information about a single filament spool including usage history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filament_id: The filament spool ID.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_filament_detail)(filament_id=filament_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_temperature_history(
|
||||||
|
printer_id: int | None = None,
|
||||||
|
hours: int = 6,
|
||||||
|
metric: str = "all",
|
||||||
|
) -> str:
|
||||||
|
"""Get temperature trends (avg/min/max) over recent hours.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
printer_id: Optional printer ID to filter.
|
||||||
|
hours: Number of hours to look back (default 6).
|
||||||
|
metric: Which sensor to show: 'all', 'nozzle', 'bed', or 'chamber'.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_temperature_history)(
|
||||||
|
printer_id=printer_id, hours=hours, metric=metric
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_filament_usage_stats(days: int = 30, group_by: str = "type") -> str:
|
||||||
|
"""Get aggregate filament consumption statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back (default 30).
|
||||||
|
group_by: Group results by 'type', 'color', or 'spool'.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_filament_usage_stats)(days=days, group_by=group_by)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_printer_health(printer_id: int | None = None) -> str:
|
||||||
|
"""Get printer diagnostics including errors, humidity, WiFi signal, and recent failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
printer_id: Optional printer ID to filter. Omit for all printers.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_printer_health)(printer_id=printer_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_print_jobs(query: str) -> str:
|
||||||
|
"""Search print jobs by project name or gcode filename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search text (partial match on project name or gcode file).
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.search_print_jobs)(query=query)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_printing_summary(days: int = 7) -> str:
|
||||||
|
"""Get high-level printing activity summary including job counts, success rate, and top projects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to summarize (default 7).
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.get_printing_summary)(days=days)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def find_compatible_filament(
|
||||||
|
type: str,
|
||||||
|
min_remaining_percent: int = 10,
|
||||||
|
color: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Find filament spools matching material type and optional criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
type: Material type to search for (PLA, PETG, ABS, etc.).
|
||||||
|
min_remaining_percent: Minimum remaining percentage (default 10).
|
||||||
|
color: Optional color filter (partial match).
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.find_compatible_filament)(
|
||||||
|
type=type, min_remaining_percent=min_remaining_percent, color=color
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Register Resources ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.resource("bambu://printers")
|
||||||
|
async def res_printers() -> str:
|
||||||
|
"""List all registered printers."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_printers)()
|
||||||
|
|
||||||
|
@mcp.resource("bambu://printers/{printer_id}/status")
|
||||||
|
async def res_printer_status(printer_id: int) -> str:
|
||||||
|
"""Get latest status for a specific printer."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_printer_status)(printer_id)
|
||||||
|
|
||||||
|
@mcp.resource("bambu://filaments")
|
||||||
|
async def res_filaments() -> str:
|
||||||
|
"""Full filament inventory."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_filaments)()
|
||||||
|
|
||||||
|
@mcp.resource("bambu://filaments/{filament_id}")
|
||||||
|
async def res_filament_detail(filament_id: int) -> str:
|
||||||
|
"""Single filament spool with usage history."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_filament_detail)(filament_id)
|
||||||
|
|
||||||
|
@mcp.resource("bambu://print-jobs/recent")
|
||||||
|
async def res_recent_jobs() -> str:
|
||||||
|
"""Last 20 print jobs."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_recent_print_jobs)()
|
||||||
|
|
||||||
|
@mcp.resource("bambu://filament-types")
|
||||||
|
async def res_filament_types() -> str:
|
||||||
|
"""Filament type registry."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_filament_types)()
|
||||||
|
|
||||||
|
@mcp.resource("bambu://filament-colors")
|
||||||
|
async def res_filament_colors() -> str:
|
||||||
|
"""Filament color database."""
|
||||||
|
return await sync_to_async(mcp_tools.resource_filament_colors)()
|
||||||
|
|
||||||
|
# ── Register Prompts ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
async def printer_check_in(printer_id: int | None = None) -> str:
|
||||||
|
"""Full printer status briefing with health check and recent prints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
printer_id: Optional printer ID. Omit for all printers.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.prompt_printer_check_in)(printer_id=printer_id)
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
async def filament_inventory_report() -> str:
|
||||||
|
"""Comprehensive filament inventory report with low-stock warnings."""
|
||||||
|
return await sync_to_async(mcp_tools.prompt_filament_inventory_report)()
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
async def print_job_review(job_id: int) -> str:
|
||||||
|
"""Detailed review of a completed print job.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: The print job ID to review.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.prompt_print_job_review)(job_id)
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
async def weekly_printing_digest() -> str:
|
||||||
|
"""Weekly printing activity summary with filament usage breakdown."""
|
||||||
|
return await sync_to_async(mcp_tools.prompt_weekly_digest)()
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
async def troubleshoot_printer(printer_id: int | None = None) -> str:
|
||||||
|
"""Diagnose printer issues using recent health data, status, and temperatures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
printer_id: Optional printer ID. Omit for all printers.
|
||||||
|
"""
|
||||||
|
return await sync_to_async(mcp_tools.prompt_troubleshoot_printer)(printer_id=printer_id)
|
||||||
|
|
||||||
|
# ── Auth middleware for SSE ───────────────────────────────────────
|
||||||
|
|
||||||
|
api_key = app_settings.MCP_API_KEY
|
||||||
|
auth_backend = app_settings.MCP_AUTH_BACKEND
|
||||||
|
|
||||||
|
if api_key or auth_backend:
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
# Custom auth backend takes priority
|
||||||
|
if auth_backend:
|
||||||
|
if not auth_backend(request):
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Unauthorized"}, status_code=401
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# API key auth
|
||||||
|
if api_key:
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header == f"Bearer {api_key}":
|
||||||
|
return await call_next(request)
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Invalid or missing API key"}, status_code=401
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Attach middleware — FastMCP's SSE app is a Starlette app
|
||||||
|
original_sse_app = mcp.sse_app
|
||||||
|
|
||||||
|
def patched_sse_app():
|
||||||
|
app = original_sse_app()
|
||||||
|
app.add_middleware(AuthMiddleware)
|
||||||
|
return app
|
||||||
|
|
||||||
|
mcp.sse_app = patched_sse_app
|
||||||
|
|
||||||
|
# ── Run ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if transport == "sse":
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
except ImportError:
|
||||||
|
raise CommandError(
|
||||||
|
"uvicorn is required for SSE transport. Install it with: pip install uvicorn"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Starting Bambu-Run MCP server (SSE) on {host}:{port}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f"Connect with: http://{host}:{port}/sse"
|
||||||
|
)
|
||||||
|
app = mcp.sse_app()
|
||||||
|
uvicorn.run(app, host=host, port=port)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)")
|
||||||
|
)
|
||||||
|
mcp.run(transport="stdio")
|
||||||
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=5)
|
||||||
|
window_end = cloud_task.cloud_start_time + timedelta(minutes=5)
|
||||||
|
historical = PrintJob.objects.filter(
|
||||||
|
device=printer,
|
||||||
|
start_time__gte=window_start,
|
||||||
|
start_time__lte=window_end,
|
||||||
|
cloud_task__isnull=True,
|
||||||
|
).update(cloud_task=cloud_task)
|
||||||
|
if historical:
|
||||||
|
linked_count += historical
|
||||||
|
self.stdout.write(
|
||||||
|
f" Historically linked {historical} PrintJob(s) by time for task {task_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(f" Error processing task {task_id}: {e}")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\nDone: {created_count} created, {updated_count} updated, "
|
||||||
|
f"{linked_count} PrintJob(s) linked"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.WARNING("\nDry run complete — no changes written"))
|
||||||
728
bambu_run/mcp_tools.py
Normal file
728
bambu_run/mcp_tools.py
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
"""
|
||||||
|
Pure Django ORM query functions for MCP tools.
|
||||||
|
|
||||||
|
Zero dependency on the `mcp` package — returns markdown strings.
|
||||||
|
RAE can reuse these directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from django.db.models import Avg, Count, Max, Min, Q, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .conf import app_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _local_dt(dt, fmt="%Y-%m-%d %H:%M %Z"):
|
||||||
|
"""Convert a UTC-aware datetime to the configured local timezone for display."""
|
||||||
|
if dt is None:
|
||||||
|
return "—"
|
||||||
|
tz = ZoneInfo(app_settings.TIMEZONE)
|
||||||
|
return dt.astimezone(tz).strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(value, label="[redacted]"):
|
||||||
|
"""Redact sensitive values if MCP_HIDE_SENSITIVE is enabled."""
|
||||||
|
if app_settings.MCP_HIDE_SENSITIVE:
|
||||||
|
return label
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _job_name(job):
|
||||||
|
"""Return the best available display name for a print job.
|
||||||
|
|
||||||
|
Prefers cloud design_title (e.g., 'Planetary Gears Finger Fidget Spinners')
|
||||||
|
over the MQTT subtask_name (e.g., 'All variants at 0.16mm high quality').
|
||||||
|
Falls back to project_name for local/SD prints with no cloud task.
|
||||||
|
"""
|
||||||
|
if job.cloud_task_id and job.cloud_task and job.cloud_task.design_title:
|
||||||
|
return job.cloud_task.design_title
|
||||||
|
return job.project_name
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(minutes):
|
||||||
|
"""Format minutes into human-readable duration."""
|
||||||
|
if minutes is None:
|
||||||
|
return "Unknown"
|
||||||
|
hours, mins = divmod(int(minutes), 60)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h {mins}m"
|
||||||
|
return f"{mins}m"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_temp(temp):
|
||||||
|
"""Format temperature value."""
|
||||||
|
if temp is None:
|
||||||
|
return "N/A"
|
||||||
|
return f"{temp}°C"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tools ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_printer_status(printer_id=None):
|
||||||
|
"""Current live status of printer(s) including temps, progress, AMS, errors."""
|
||||||
|
from .models import Printer, PrinterMetrics
|
||||||
|
|
||||||
|
printers = Printer.objects.filter(is_active=True)
|
||||||
|
if printer_id:
|
||||||
|
printers = printers.filter(id=printer_id)
|
||||||
|
|
||||||
|
if not printers.exists():
|
||||||
|
return "No printers found."
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for printer in printers:
|
||||||
|
metric = PrinterMetrics.objects.filter(device=printer).first()
|
||||||
|
if not metric:
|
||||||
|
parts.append(f"## {printer.name}\n**No data available yet.**\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
state = metric.gcode_state or "Unknown"
|
||||||
|
lines = [f"## Printer Status: {printer.name}"]
|
||||||
|
lines.append(f"**Model**: {printer.model} | **Serial**: {_redact(printer.serial_number)}")
|
||||||
|
lines.append(f"**IP**: {_redact(printer.ip_address)} | **Location**: {printer.location or 'N/A'}")
|
||||||
|
lines.append(f"**State**: {state}")
|
||||||
|
|
||||||
|
if metric.print_percent is not None and state == "RUNNING":
|
||||||
|
layer_info = ""
|
||||||
|
if metric.layer_num is not None and metric.total_layer_num:
|
||||||
|
layer_info = f" (Layer {metric.layer_num}/{metric.total_layer_num})"
|
||||||
|
lines.append(f"**Progress**: {metric.print_percent}%{layer_info}")
|
||||||
|
if metric.subtask_name:
|
||||||
|
lines.append(f"**Project**: {metric.subtask_name}")
|
||||||
|
if metric.remaining_time_min:
|
||||||
|
lines.append(f"**ETA**: {_format_duration(metric.remaining_time_min)} remaining")
|
||||||
|
|
||||||
|
# Temperatures
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Temperatures")
|
||||||
|
lines.append("| Component | Current | Target |")
|
||||||
|
lines.append("|-----------|---------|--------|")
|
||||||
|
lines.append(f"| Nozzle | {_format_temp(metric.nozzle_temp)} | {_format_temp(metric.nozzle_target_temp)} |")
|
||||||
|
lines.append(f"| Bed | {_format_temp(metric.bed_temp)} | {_format_temp(metric.bed_target_temp)} |")
|
||||||
|
lines.append(f"| Chamber | {_format_temp(metric.chamber_temp)} | - |")
|
||||||
|
|
||||||
|
# AMS filaments from JSON
|
||||||
|
if metric.filaments:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### AMS Slots")
|
||||||
|
lines.append("| Slot | Material | Color | Remaining |")
|
||||||
|
lines.append("|------|----------|-------|-----------|")
|
||||||
|
for f in metric.filaments:
|
||||||
|
slot = f.get("slot", "?")
|
||||||
|
ftype = f.get("sub_type") or f.get("type", "?")
|
||||||
|
color = f.get("color", "")
|
||||||
|
color_display = f"#{color[:6]}" if color and len(color) >= 6 else "?"
|
||||||
|
remain = f.get("remain_percent", "?")
|
||||||
|
lines.append(f"| {slot} | {ftype} | {color_display} | {remain}% |")
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
if metric.has_errors or metric.hms:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Alerts")
|
||||||
|
if metric.print_error:
|
||||||
|
lines.append(f"- Print error code: {metric.print_error}")
|
||||||
|
if metric.hms:
|
||||||
|
for msg in metric.hms[:5]:
|
||||||
|
lines.append(f"- HMS: {msg}")
|
||||||
|
|
||||||
|
lines.append(f"\n*Last updated: {_local_dt(metric.timestamp, '%Y-%m-%d %H:%M:%S %Z')}*")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def list_printers():
|
||||||
|
"""List all registered printers."""
|
||||||
|
from .models import Printer
|
||||||
|
|
||||||
|
printers = Printer.objects.all()
|
||||||
|
if not printers.exists():
|
||||||
|
return "No printers registered."
|
||||||
|
|
||||||
|
lines = ["# Printers", ""]
|
||||||
|
lines.append("| ID | Name | Model | Active | Serial | IP | Location |")
|
||||||
|
lines.append("|----|------|-------|--------|--------|----|----------|")
|
||||||
|
for p in printers:
|
||||||
|
lines.append(
|
||||||
|
f"| {p.id} | {p.name} | {p.model} | "
|
||||||
|
f"{'Yes' if p.is_active else 'No'} | "
|
||||||
|
f"{_redact(p.serial_number)} | {_redact(p.ip_address)} | "
|
||||||
|
f"{p.location or '-'} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_print_history(status=None, days=None, project_name=None, limit=20):
|
||||||
|
"""Print job history with optional filters."""
|
||||||
|
from .models import PrintJob
|
||||||
|
|
||||||
|
qs = PrintJob.objects.select_related("device", "cloud_task")
|
||||||
|
|
||||||
|
if status:
|
||||||
|
qs = qs.filter(final_status__iexact=status)
|
||||||
|
if days:
|
||||||
|
cutoff = timezone.now() - timedelta(days=int(days))
|
||||||
|
qs = qs.filter(start_time__gte=cutoff)
|
||||||
|
if project_name:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(project_name__icontains=project_name)
|
||||||
|
| Q(cloud_task__design_title__icontains=project_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs = qs[:int(limit)]
|
||||||
|
if not jobs:
|
||||||
|
return "No print jobs found matching the criteria."
|
||||||
|
|
||||||
|
lines = ["# Print History", ""]
|
||||||
|
lines.append("| ID | Project | Printer | Status | Progress | Duration | Started |")
|
||||||
|
lines.append("|----|---------|---------|--------|----------|----------|---------|")
|
||||||
|
for j in jobs:
|
||||||
|
lines.append(
|
||||||
|
f"| {j.id} | {_job_name(j)} | {j.device.name} | "
|
||||||
|
f"{j.final_status or 'In Progress'} | {j.completion_percent}% | "
|
||||||
|
f"{_format_duration(j.duration_minutes)} | "
|
||||||
|
f"{_local_dt(j.start_time, '%Y-%m-%d %H:%M')} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_print_job_detail(job_id):
|
||||||
|
"""Single job detail including filament usage."""
|
||||||
|
from .models import FilamentUsage, PrintJob
|
||||||
|
|
||||||
|
try:
|
||||||
|
job = PrintJob.objects.select_related("device", "cloud_task").get(id=job_id)
|
||||||
|
except PrintJob.DoesNotExist:
|
||||||
|
return f"Print job #{job_id} not found."
|
||||||
|
|
||||||
|
lines = [f"# Print Job: {_job_name(job)}", ""]
|
||||||
|
if job.cloud_task and job.cloud_task.design_title and job.cloud_task.design_title != job.project_name:
|
||||||
|
lines.append(f"**Plate**: {job.project_name}")
|
||||||
|
lines.append(f"**Printer**: {job.device.name}")
|
||||||
|
lines.append(f"**Status**: {job.final_status or 'In Progress'}")
|
||||||
|
lines.append(f"**Progress**: {job.completion_percent}%")
|
||||||
|
if job.gcode_file:
|
||||||
|
lines.append(f"**G-code**: {job.gcode_file}")
|
||||||
|
lines.append(f"**Started**: {_local_dt(job.start_time, '%Y-%m-%d %H:%M:%S %Z')}")
|
||||||
|
if job.end_time:
|
||||||
|
lines.append(f"**Ended**: {_local_dt(job.end_time, '%Y-%m-%d %H:%M:%S %Z')}")
|
||||||
|
lines.append(f"**Duration**: {_format_duration(job.duration_minutes)}")
|
||||||
|
if job.total_layers:
|
||||||
|
lines.append(f"**Total Layers**: {job.total_layers}")
|
||||||
|
|
||||||
|
# Filament usage
|
||||||
|
usages = FilamentUsage.objects.select_related("filament").filter(print_job=job)
|
||||||
|
if usages.exists():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Filament Usage")
|
||||||
|
lines.append("| Spool | Material | Color | Consumed | Grams |")
|
||||||
|
lines.append("|-------|----------|-------|----------|-------|")
|
||||||
|
for u in usages:
|
||||||
|
f = u.filament
|
||||||
|
lines.append(
|
||||||
|
f"| {f.brand} {f.type} | {f.sub_type or f.type} | "
|
||||||
|
f"{f.color} | {u.consumed_percent or 0}% | "
|
||||||
|
f"{u.consumed_grams or '-'}g |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def list_filaments(type=None, brand=None, color=None, loaded_in_ams=None, low_filament=None):
|
||||||
|
"""Filament inventory with optional filters."""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
qs = Filament.objects.all()
|
||||||
|
if type:
|
||||||
|
qs = qs.filter(type__iexact=type)
|
||||||
|
if brand:
|
||||||
|
qs = qs.filter(brand__icontains=brand)
|
||||||
|
if color:
|
||||||
|
qs = qs.filter(color__icontains=color)
|
||||||
|
if loaded_in_ams is not None:
|
||||||
|
qs = qs.filter(is_loaded_in_ams=loaded_in_ams)
|
||||||
|
if low_filament:
|
||||||
|
qs = qs.filter(remaining_percent__lte=20)
|
||||||
|
|
||||||
|
filaments = qs[:50]
|
||||||
|
if not filaments:
|
||||||
|
return "No filaments found matching the criteria."
|
||||||
|
|
||||||
|
lines = ["# Filament Inventory", ""]
|
||||||
|
lines.append(f"*{qs.count()} spools total*\n")
|
||||||
|
lines.append("| ID | Brand | Type | Color | Remaining | In AMS | Last Used |")
|
||||||
|
lines.append("|----|-------|------|-------|-----------|--------|-----------|")
|
||||||
|
for f in filaments:
|
||||||
|
color_display = f"{f.color}"
|
||||||
|
if f.color_hex:
|
||||||
|
color_display += f" ({f.color_hex})"
|
||||||
|
last_used = _local_dt(f.last_used, "%Y-%m-%d") if f.last_used else "-"
|
||||||
|
lines.append(
|
||||||
|
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
|
||||||
|
f"{color_display} | {f.remaining_percent}% | "
|
||||||
|
f"{'Yes' if f.is_loaded_in_ams else 'No'} | {last_used} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filament_detail(filament_id):
|
||||||
|
"""Single spool detail with usage history."""
|
||||||
|
from .models import Filament, FilamentUsage
|
||||||
|
|
||||||
|
try:
|
||||||
|
f = Filament.objects.get(id=filament_id)
|
||||||
|
except Filament.DoesNotExist:
|
||||||
|
return f"Filament #{filament_id} not found."
|
||||||
|
|
||||||
|
lines = [f"# Filament: {f.brand} {f.type} - {f.color}", ""]
|
||||||
|
lines.append(f"**Type**: {f.sub_type or f.type}")
|
||||||
|
lines.append(f"**Brand**: {f.brand}")
|
||||||
|
lines.append(f"**Color**: {f.color} ({f.color_hex or 'N/A'})")
|
||||||
|
lines.append(f"**Remaining**: {f.remaining_percent}%")
|
||||||
|
if f.remaining_weight_grams:
|
||||||
|
lines.append(f"**Remaining Weight**: {f.remaining_weight_grams}g / {f.initial_weight_grams or '?'}g")
|
||||||
|
lines.append(f"**In AMS**: {'Yes (slot ' + str(f.current_tray_id) + ')' if f.is_loaded_in_ams else 'No'}")
|
||||||
|
lines.append(f"**Created By**: {f.created_by}")
|
||||||
|
if f.tray_uuid:
|
||||||
|
lines.append(f"**Serial**: {_redact(f.tray_uuid)}")
|
||||||
|
if f.purchase_date:
|
||||||
|
lines.append(f"**Purchased**: {f.purchase_date}")
|
||||||
|
if f.notes:
|
||||||
|
lines.append(f"**Notes**: {f.notes}")
|
||||||
|
|
||||||
|
# Usage history
|
||||||
|
usages = FilamentUsage.objects.select_related("print_job").filter(filament=f).order_by("-print_job__start_time")[:10]
|
||||||
|
if usages.exists():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Recent Print Usage")
|
||||||
|
lines.append("| Job | Date | Consumed | Grams |")
|
||||||
|
lines.append("|-----|------|----------|-------|")
|
||||||
|
for u in usages:
|
||||||
|
lines.append(
|
||||||
|
f"| {u.print_job.project_name} | "
|
||||||
|
f"{_local_dt(u.print_job.start_time, '%Y-%m-%d')} | "
|
||||||
|
f"{u.consumed_percent or 0}% | {u.consumed_grams or '-'}g |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_temperature_history(printer_id=None, hours=6, metric="all"):
|
||||||
|
"""Temperature trends as summary stats (avg/min/max) over recent hours."""
|
||||||
|
from .models import Printer, PrinterMetrics
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(hours=int(hours))
|
||||||
|
|
||||||
|
qs = PrinterMetrics.objects.filter(timestamp__gte=cutoff)
|
||||||
|
if printer_id:
|
||||||
|
qs = qs.filter(device_id=printer_id)
|
||||||
|
|
||||||
|
if not qs.exists():
|
||||||
|
return f"No temperature data in the last {hours} hours."
|
||||||
|
|
||||||
|
printers = Printer.objects.filter(
|
||||||
|
id__in=qs.values_list("device_id", flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
parts = [f"# Temperature History (last {hours}h)", ""]
|
||||||
|
for printer in printers:
|
||||||
|
pqs = qs.filter(device=printer)
|
||||||
|
stats = pqs.aggregate(
|
||||||
|
nozzle_avg=Avg("nozzle_temp"),
|
||||||
|
nozzle_min=Min("nozzle_temp"),
|
||||||
|
nozzle_max=Max("nozzle_temp"),
|
||||||
|
bed_avg=Avg("bed_temp"),
|
||||||
|
bed_min=Min("bed_temp"),
|
||||||
|
bed_max=Max("bed_temp"),
|
||||||
|
chamber_avg=Avg("chamber_temp"),
|
||||||
|
chamber_min=Min("chamber_temp"),
|
||||||
|
chamber_max=Max("chamber_temp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append(f"## {printer.name}")
|
||||||
|
parts.append(f"*{pqs.count()} data points*\n")
|
||||||
|
parts.append("| Sensor | Avg | Min | Max |")
|
||||||
|
parts.append("|--------|-----|-----|-----|")
|
||||||
|
|
||||||
|
if metric in ("all", "nozzle"):
|
||||||
|
parts.append(
|
||||||
|
f"| Nozzle | {_format_temp(stats['nozzle_avg'])} | "
|
||||||
|
f"{_format_temp(stats['nozzle_min'])} | {_format_temp(stats['nozzle_max'])} |"
|
||||||
|
)
|
||||||
|
if metric in ("all", "bed"):
|
||||||
|
parts.append(
|
||||||
|
f"| Bed | {_format_temp(stats['bed_avg'])} | "
|
||||||
|
f"{_format_temp(stats['bed_min'])} | {_format_temp(stats['bed_max'])} |"
|
||||||
|
)
|
||||||
|
if metric in ("all", "chamber"):
|
||||||
|
parts.append(
|
||||||
|
f"| Chamber | {_format_temp(stats['chamber_avg'])} | "
|
||||||
|
f"{_format_temp(stats['chamber_min'])} | {_format_temp(stats['chamber_max'])} |"
|
||||||
|
)
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filament_usage_stats(days=30, group_by="type"):
|
||||||
|
"""Aggregate filament consumption statistics."""
|
||||||
|
from .models import FilamentUsage
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=int(days))
|
||||||
|
qs = FilamentUsage.objects.filter(
|
||||||
|
print_job__start_time__gte=cutoff,
|
||||||
|
consumed_grams__isnull=False,
|
||||||
|
).select_related("filament")
|
||||||
|
|
||||||
|
if not qs.exists():
|
||||||
|
return f"No filament usage data in the last {days} days."
|
||||||
|
|
||||||
|
lines = [f"# Filament Usage Stats (last {days} days)", ""]
|
||||||
|
|
||||||
|
if group_by == "type":
|
||||||
|
stats = (
|
||||||
|
qs.values("filament__type")
|
||||||
|
.annotate(
|
||||||
|
total_grams=Sum("consumed_grams"),
|
||||||
|
total_percent=Sum("consumed_percent"),
|
||||||
|
job_count=Count("print_job", distinct=True),
|
||||||
|
)
|
||||||
|
.order_by("-total_grams")
|
||||||
|
)
|
||||||
|
lines.append("| Type | Total Grams | Jobs | Avg Grams/Job |")
|
||||||
|
lines.append("|------|-------------|------|---------------|")
|
||||||
|
for s in stats:
|
||||||
|
avg = s["total_grams"] / s["job_count"] if s["job_count"] else 0
|
||||||
|
lines.append(
|
||||||
|
f"| {s['filament__type']} | {s['total_grams']}g | "
|
||||||
|
f"{s['job_count']} | {avg:.0f}g |"
|
||||||
|
)
|
||||||
|
elif group_by == "color":
|
||||||
|
stats = (
|
||||||
|
qs.values("filament__color", "filament__type")
|
||||||
|
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
|
||||||
|
.order_by("-total_grams")
|
||||||
|
)
|
||||||
|
lines.append("| Color | Type | Total Grams | Jobs |")
|
||||||
|
lines.append("|-------|------|-------------|------|")
|
||||||
|
for s in stats:
|
||||||
|
lines.append(
|
||||||
|
f"| {s['filament__color']} | {s['filament__type']} | "
|
||||||
|
f"{s['total_grams']}g | {s['job_count']} |"
|
||||||
|
)
|
||||||
|
elif group_by == "spool":
|
||||||
|
stats = (
|
||||||
|
qs.values("filament__id", "filament__brand", "filament__type", "filament__color")
|
||||||
|
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
|
||||||
|
.order_by("-total_grams")[:20]
|
||||||
|
)
|
||||||
|
lines.append("| Spool | Total Grams | Jobs |")
|
||||||
|
lines.append("|-------|-------------|------|")
|
||||||
|
for s in stats:
|
||||||
|
lines.append(
|
||||||
|
f"| {s['filament__brand']} {s['filament__type']} {s['filament__color']} | "
|
||||||
|
f"{s['total_grams']}g | {s['job_count']} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_printer_health(printer_id=None):
|
||||||
|
"""Diagnostics: errors, humidity, wifi, recent failed prints."""
|
||||||
|
from .models import Printer, PrinterMetrics, PrintJob
|
||||||
|
|
||||||
|
printers = Printer.objects.filter(is_active=True)
|
||||||
|
if printer_id:
|
||||||
|
printers = printers.filter(id=printer_id)
|
||||||
|
|
||||||
|
if not printers.exists():
|
||||||
|
return "No printers found."
|
||||||
|
|
||||||
|
parts = ["# Printer Health Report", ""]
|
||||||
|
for printer in printers:
|
||||||
|
latest = PrinterMetrics.objects.filter(device=printer).first()
|
||||||
|
if not latest:
|
||||||
|
parts.append(f"## {printer.name}\n**No data available.**\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts.append(f"## {printer.name}")
|
||||||
|
|
||||||
|
# Connectivity
|
||||||
|
parts.append("### Connectivity")
|
||||||
|
if latest.wifi_signal_dbm is not None:
|
||||||
|
signal = latest.wifi_signal_dbm
|
||||||
|
quality = "Excellent" if signal > -50 else "Good" if signal > -60 else "Fair" if signal > -70 else "Poor"
|
||||||
|
parts.append(f"- WiFi: {signal} dBm ({quality})")
|
||||||
|
parts.append(f"- Last seen: {_local_dt(latest.timestamp, '%Y-%m-%d %H:%M:%S %Z')}")
|
||||||
|
age = (timezone.now() - latest.timestamp).total_seconds()
|
||||||
|
if age > 300:
|
||||||
|
parts.append(f"- **Warning**: No data for {_format_duration(age / 60)}")
|
||||||
|
|
||||||
|
# AMS environment
|
||||||
|
if latest.ams_humidity is not None or latest.ams_temp is not None:
|
||||||
|
parts.append("### AMS Environment")
|
||||||
|
if latest.ams_humidity is not None:
|
||||||
|
hum_status = "OK" if latest.ams_humidity < 5 else "High" if latest.ams_humidity < 8 else "Critical"
|
||||||
|
parts.append(f"- Humidity: {latest.ams_humidity} ({hum_status})")
|
||||||
|
if latest.ams_temp is not None:
|
||||||
|
parts.append(f"- Temperature: {latest.ams_temp}°C")
|
||||||
|
|
||||||
|
# HMS errors
|
||||||
|
if latest.hms:
|
||||||
|
parts.append("### Active HMS Alerts")
|
||||||
|
for msg in latest.hms:
|
||||||
|
parts.append(f"- {msg}")
|
||||||
|
|
||||||
|
# Recent failures
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
failed = PrintJob.objects.filter(
|
||||||
|
device=printer,
|
||||||
|
start_time__gte=week_ago,
|
||||||
|
final_status__in=["FAILED", "CANCELLED"],
|
||||||
|
)
|
||||||
|
if failed.exists():
|
||||||
|
parts.append(f"### Recent Failures (7d): {failed.count()}")
|
||||||
|
for job in failed.select_related("cloud_task")[:5]:
|
||||||
|
parts.append(f"- {_job_name(job)} ({job.final_status}) — {_local_dt(job.start_time, '%m-%d %H:%M')}")
|
||||||
|
|
||||||
|
# Success rate
|
||||||
|
week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago)
|
||||||
|
total = week_jobs.count()
|
||||||
|
if total > 0:
|
||||||
|
success = week_jobs.filter(final_status="FINISH").count()
|
||||||
|
parts.append(f"\n**7-day success rate**: {success}/{total} ({100 * success // total}%)")
|
||||||
|
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def search_print_jobs(query):
|
||||||
|
"""Search print jobs by project name or gcode file."""
|
||||||
|
from .models import PrintJob
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return "Please provide a search query."
|
||||||
|
|
||||||
|
jobs = PrintJob.objects.select_related("device", "cloud_task").filter(
|
||||||
|
Q(project_name__icontains=query)
|
||||||
|
| Q(gcode_file__icontains=query)
|
||||||
|
| Q(cloud_task__design_title__icontains=query)
|
||||||
|
)[:20]
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
return f"No print jobs matching '{query}'."
|
||||||
|
|
||||||
|
lines = [f"# Search Results: '{query}'", ""]
|
||||||
|
lines.append(f"*{len(jobs)} results*\n")
|
||||||
|
lines.append("| ID | Project | Printer | Status | Date |")
|
||||||
|
lines.append("|----|---------|---------|--------|------|")
|
||||||
|
for j in jobs:
|
||||||
|
lines.append(
|
||||||
|
f"| {j.id} | {_job_name(j)} | {j.device.name} | "
|
||||||
|
f"{j.final_status or 'In Progress'} | {_local_dt(j.start_time, '%Y-%m-%d')} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_printing_summary(days=7):
|
||||||
|
"""High-level activity summary."""
|
||||||
|
from .models import FilamentUsage, Printer, PrintJob
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=int(days))
|
||||||
|
jobs = PrintJob.objects.filter(start_time__gte=cutoff)
|
||||||
|
|
||||||
|
total = jobs.count()
|
||||||
|
finished = jobs.filter(final_status="FINISH").count()
|
||||||
|
failed = jobs.filter(final_status="FAILED").count()
|
||||||
|
cancelled = jobs.filter(final_status="CANCELLED").count()
|
||||||
|
in_progress = jobs.filter(final_status__isnull=True).count()
|
||||||
|
|
||||||
|
total_minutes = jobs.filter(duration_minutes__isnull=False).aggregate(
|
||||||
|
total=Sum("duration_minutes")
|
||||||
|
)["total"] or 0
|
||||||
|
|
||||||
|
total_grams = FilamentUsage.objects.filter(
|
||||||
|
print_job__start_time__gte=cutoff,
|
||||||
|
consumed_grams__isnull=False,
|
||||||
|
).aggregate(total=Sum("consumed_grams"))["total"] or 0
|
||||||
|
|
||||||
|
lines = [f"# Printing Summary (last {days} days)", ""]
|
||||||
|
lines.append(f"**Total Jobs**: {total}")
|
||||||
|
lines.append(f"- Completed: {finished}")
|
||||||
|
lines.append(f"- Failed: {failed}")
|
||||||
|
lines.append(f"- Cancelled: {cancelled}")
|
||||||
|
lines.append(f"- In Progress: {in_progress}")
|
||||||
|
if total > 0:
|
||||||
|
lines.append(f"- Success Rate: {100 * finished // total}%")
|
||||||
|
lines.append(f"\n**Total Print Time**: {_format_duration(total_minutes)}")
|
||||||
|
lines.append(f"**Total Filament Used**: {total_grams}g")
|
||||||
|
|
||||||
|
# Most printed projects
|
||||||
|
top_projects = (
|
||||||
|
jobs.values("project_name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:5]
|
||||||
|
)
|
||||||
|
if top_projects:
|
||||||
|
lines.append("\n### Most Printed")
|
||||||
|
for p in top_projects:
|
||||||
|
lines.append(f"- {p['project_name']} ({p['count']}x)")
|
||||||
|
|
||||||
|
# Active printers
|
||||||
|
active_printers = Printer.objects.filter(
|
||||||
|
print_jobs__start_time__gte=cutoff
|
||||||
|
).distinct()
|
||||||
|
if active_printers.exists():
|
||||||
|
lines.append(f"\n**Active Printers**: {', '.join(p.name for p in active_printers)}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def find_compatible_filament(type, min_remaining_percent=10, color=None):
|
||||||
|
"""Find spools matching material type criteria."""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
qs = Filament.objects.filter(
|
||||||
|
type__iexact=type,
|
||||||
|
remaining_percent__gte=int(min_remaining_percent),
|
||||||
|
)
|
||||||
|
if color:
|
||||||
|
qs = qs.filter(color__icontains=color)
|
||||||
|
|
||||||
|
filaments = qs[:20]
|
||||||
|
if not filaments:
|
||||||
|
return f"No {type} filament found with >={min_remaining_percent}% remaining."
|
||||||
|
|
||||||
|
lines = [f"# Compatible Filament: {type}", ""]
|
||||||
|
if color:
|
||||||
|
lines.append(f"*Color filter: {color}*\n")
|
||||||
|
lines.append(f"*{qs.count()} spools found*\n")
|
||||||
|
lines.append("| ID | Brand | Sub-type | Color | Remaining | In AMS |")
|
||||||
|
lines.append("|----|-------|----------|-------|-----------|--------|")
|
||||||
|
for f in filaments:
|
||||||
|
lines.append(
|
||||||
|
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
|
||||||
|
f"{f.color} | {f.remaining_percent}% | "
|
||||||
|
f"{'Yes' if f.is_loaded_in_ams else 'No'} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Resources ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def resource_printers():
|
||||||
|
"""List all printers (resource)."""
|
||||||
|
return list_printers()
|
||||||
|
|
||||||
|
|
||||||
|
def resource_printer_status(printer_id):
|
||||||
|
"""Latest printer status (resource)."""
|
||||||
|
return get_printer_status(printer_id=printer_id)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filaments():
|
||||||
|
"""Full filament inventory (resource)."""
|
||||||
|
return list_filaments()
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filament_detail(filament_id):
|
||||||
|
"""Single spool with usage (resource)."""
|
||||||
|
return get_filament_detail(filament_id=filament_id)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_recent_print_jobs():
|
||||||
|
"""Last 20 print jobs (resource)."""
|
||||||
|
return get_print_history(limit=20)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filament_types():
|
||||||
|
"""Filament type registry (resource)."""
|
||||||
|
from .models import FilamentType
|
||||||
|
|
||||||
|
types = FilamentType.objects.all()
|
||||||
|
if not types.exists():
|
||||||
|
return "No filament types registered."
|
||||||
|
|
||||||
|
lines = ["# Filament Types", ""]
|
||||||
|
lines.append("| ID | Type | Sub-type | Brand |")
|
||||||
|
lines.append("|----|------|----------|-------|")
|
||||||
|
for t in types:
|
||||||
|
lines.append(f"| {t.id} | {t.type} | {t.sub_type or '-'} | {t.brand} |")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filament_colors():
|
||||||
|
"""Filament color database (resource)."""
|
||||||
|
from .models import FilamentColor
|
||||||
|
|
||||||
|
colors = FilamentColor.objects.all()[:100]
|
||||||
|
if not colors:
|
||||||
|
return "No filament colors in database."
|
||||||
|
|
||||||
|
lines = ["# Filament Colors", ""]
|
||||||
|
lines.append(f"*Showing up to 100 of {FilamentColor.objects.count()}*\n")
|
||||||
|
lines.append("| Color | Hex | Type | Sub-type | Brand |")
|
||||||
|
lines.append("|-------|-----|------|----------|-------|")
|
||||||
|
for c in colors:
|
||||||
|
lines.append(
|
||||||
|
f"| {c.color_name} | #{c.color_code} | {c.filament_type} | "
|
||||||
|
f"{c.filament_sub_type or '-'} | {c.brand} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Prompts ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_printer_check_in(printer_id=None):
|
||||||
|
"""Full status briefing: status + health + recent prints."""
|
||||||
|
parts = [
|
||||||
|
get_printer_status(printer_id=printer_id),
|
||||||
|
get_printer_health(printer_id=printer_id),
|
||||||
|
get_print_history(days=1, limit=5),
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_filament_inventory_report():
|
||||||
|
"""Inventory report with low-stock warnings."""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
low_stock = Filament.objects.filter(remaining_percent__lte=20)
|
||||||
|
parts = [list_filaments()]
|
||||||
|
if low_stock.exists():
|
||||||
|
lines = ["\n## Low Stock Warnings"]
|
||||||
|
for f in low_stock:
|
||||||
|
lines.append(f"- **{f.brand} {f.type} {f.color}**: {f.remaining_percent}% remaining")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_print_job_review(job_id):
|
||||||
|
"""Review a completed job."""
|
||||||
|
return get_print_job_detail(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_weekly_digest():
|
||||||
|
"""Weekly activity summary."""
|
||||||
|
parts = [
|
||||||
|
get_printing_summary(days=7),
|
||||||
|
get_filament_usage_stats(days=7, group_by="type"),
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_troubleshoot_printer(printer_id=None):
|
||||||
|
"""Diagnose issues from recent data."""
|
||||||
|
parts = [
|
||||||
|
get_printer_health(printer_id=printer_id),
|
||||||
|
get_printer_status(printer_id=printer_id),
|
||||||
|
get_temperature_history(printer_id=printer_id, hours=2),
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
90
bambu_run/migrations/0004_h2c_dual_nozzle_and_ams_fields.py
Normal file
90
bambu_run/migrations/0004_h2c_dual_nozzle_and_ams_fields.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-05-07 04:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bambu_run", "0003_cloud_task"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="filament",
|
||||||
|
name="ams_type",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("AMS", "AMS"),
|
||||||
|
("AMS 2 Pro", "AMS 2 Pro"),
|
||||||
|
("AMS HT", "AMS HT"),
|
||||||
|
],
|
||||||
|
default="",
|
||||||
|
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="filament",
|
||||||
|
name="ams_unit_id",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printermetrics",
|
||||||
|
name="nozzle_diameter_left",
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Left nozzle diameter (mm). H2C only.",
|
||||||
|
max_digits=3,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printermetrics",
|
||||||
|
name="nozzle_target_temp_left",
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Left extruder target temperature (°C). H2C only.",
|
||||||
|
max_digits=5,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printermetrics",
|
||||||
|
name="nozzle_temp_left",
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Left extruder current temperature (°C). H2C only.",
|
||||||
|
max_digits=5,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printermetrics",
|
||||||
|
name="nozzle_type_left",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Left nozzle type (e.g. HS01-0.4). H2C only.",
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="filament",
|
||||||
|
name="current_tray_id",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,6 +2,33 @@ from django.db import models
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
# Bambu AMS model-code → human-readable type label.
|
||||||
|
# Source: live H2C MQTT probe — `print.ams.ams[i].info` field.
|
||||||
|
# Add new codes as they are observed (e.g. AMS Lite, future variants).
|
||||||
|
AMS_INFO_TO_TYPE = {
|
||||||
|
"1001": "AMS",
|
||||||
|
"1003": "AMS 2 Pro",
|
||||||
|
"2104": "AMS HT",
|
||||||
|
}
|
||||||
|
|
||||||
|
AMS_TYPE_CHOICES = [
|
||||||
|
("AMS", "AMS"),
|
||||||
|
("AMS 2 Pro", "AMS 2 Pro"),
|
||||||
|
("AMS HT", "AMS HT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ams_type_from_info(info_code) -> str:
|
||||||
|
"""Resolve an AMS unit's `info` model code to a human label.
|
||||||
|
|
||||||
|
The HT unit reports its `id` with the 0x80 bit set (e.g. 128) — when the info
|
||||||
|
code is unknown, that bit is a reasonable secondary hint for HT identification.
|
||||||
|
"""
|
||||||
|
if info_code is None:
|
||||||
|
return ""
|
||||||
|
return AMS_INFO_TO_TYPE.get(str(info_code), "")
|
||||||
|
|
||||||
|
|
||||||
class Printer(models.Model):
|
class Printer(models.Model):
|
||||||
"""Represents a Bambu Lab 3D printer device"""
|
"""Represents a Bambu Lab 3D printer device"""
|
||||||
|
|
||||||
@@ -58,12 +85,32 @@ class PrinterMetrics(models.Model):
|
|||||||
max_digits=5, decimal_places=2, null=True, blank=True
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nozzle info
|
# Nozzle info — single-nozzle / right-side back-compat fields. On dual-nozzle
|
||||||
|
# printers (H2C) these mirror the right extruder; the left extruder uses the
|
||||||
|
# `_left` columns below.
|
||||||
nozzle_diameter = models.DecimalField(
|
nozzle_diameter = models.DecimalField(
|
||||||
max_digits=3, decimal_places=2, null=True, blank=True
|
max_digits=3, decimal_places=2, null=True, blank=True
|
||||||
)
|
)
|
||||||
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
|
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
# H2C dual-nozzle: left-side fields (NULL on single-nozzle printers).
|
||||||
|
nozzle_temp_left = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||||
|
help_text="Left extruder current temperature (°C). H2C only."
|
||||||
|
)
|
||||||
|
nozzle_target_temp_left = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||||
|
help_text="Left extruder target temperature (°C). H2C only."
|
||||||
|
)
|
||||||
|
nozzle_diameter_left = models.DecimalField(
|
||||||
|
max_digits=3, decimal_places=2, null=True, blank=True,
|
||||||
|
help_text="Left nozzle diameter (mm). H2C only."
|
||||||
|
)
|
||||||
|
nozzle_type_left = models.CharField(
|
||||||
|
max_length=50, null=True, blank=True,
|
||||||
|
help_text="Left nozzle type (e.g. HS01-0.4). H2C only."
|
||||||
|
)
|
||||||
|
|
||||||
# Print job status
|
# Print job status
|
||||||
gcode_state = models.CharField(
|
gcode_state = models.CharField(
|
||||||
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
|
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
|
||||||
@@ -365,7 +412,16 @@ class Filament(models.Model):
|
|||||||
)
|
)
|
||||||
current_tray_id = models.IntegerField(
|
current_tray_id = models.IntegerField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
help_text="Which AMS slot (0-3) if loaded"
|
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)"
|
||||||
|
)
|
||||||
|
ams_unit_id = models.PositiveSmallIntegerField(
|
||||||
|
null=True, blank=True, db_index=True,
|
||||||
|
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||||
|
)
|
||||||
|
ams_type = models.CharField(
|
||||||
|
max_length=32, blank=True, default="",
|
||||||
|
choices=AMS_TYPE_CHOICES,
|
||||||
|
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)"
|
||||||
)
|
)
|
||||||
last_loaded_date = models.DateTimeField(
|
last_loaded_date = models.DateTimeField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
@@ -492,6 +548,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 +602,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")
|
||||||
@@ -544,6 +651,13 @@ class PrintJob(models.Model):
|
|||||||
status = self.final_status or 'In Progress'
|
status = self.final_status or 'In Progress'
|
||||||
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self):
|
||||||
|
"""Human-readable job name: cloud design_title if available, else project_name."""
|
||||||
|
if self.cloud_task_id and self.cloud_task and self.cloud_task.design_title:
|
||||||
|
return self.cloud_task.design_title
|
||||||
|
return self.project_name
|
||||||
|
|
||||||
def calculate_duration(self):
|
def calculate_duration(self):
|
||||||
"""Calculate print duration if end_time is set"""
|
"""Calculate print duration if end_time is set"""
|
||||||
if self.end_time and self.start_time:
|
if self.end_time and self.start_time:
|
||||||
|
|||||||
@@ -335,10 +335,16 @@ class PrinterState:
|
|||||||
wifi_signal: str = ""
|
wifi_signal: str = ""
|
||||||
wifi_signal_dbm: int = 0
|
wifi_signal_dbm: int = 0
|
||||||
|
|
||||||
# Nozzle info
|
# Nozzle info — single-nozzle / right-side back-compat fields.
|
||||||
nozzle_diameter: float = 0.4
|
nozzle_diameter: float = 0.4
|
||||||
nozzle_type: str = ""
|
nozzle_type: str = ""
|
||||||
|
|
||||||
|
# H2C dual-nozzle: left-side fields (None on single-nozzle printers).
|
||||||
|
nozzle_temp_left: Optional[float] = None
|
||||||
|
nozzle_target_temp_left: Optional[float] = None
|
||||||
|
nozzle_diameter_left: Optional[float] = None
|
||||||
|
nozzle_type_left: Optional[str] = None
|
||||||
|
|
||||||
# System status
|
# System status
|
||||||
home_flag: int = 0
|
home_flag: int = 0
|
||||||
hw_switch_state: int = 0
|
hw_switch_state: int = 0
|
||||||
@@ -410,6 +416,21 @@ class PrinterState:
|
|||||||
|
|
||||||
wifi_signal = print_data.get("wifi_signal", "")
|
wifi_signal = print_data.get("wifi_signal", "")
|
||||||
|
|
||||||
|
# H2C dual-nozzle decoding. The H2C reports per-extruder temperatures
|
||||||
|
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
|
||||||
|
# right, index 1 = left). The `temp` field is bit-packed:
|
||||||
|
# `temp_raw = (target << 16) | current`, both °C as ints.
|
||||||
|
nozzle_temp_left = None
|
||||||
|
nozzle_target_temp_left = None
|
||||||
|
device = print_data.get("device") or {}
|
||||||
|
extruders = (device.get("extruder") or {}).get("info") or []
|
||||||
|
if len(extruders) >= 2:
|
||||||
|
left = extruders[1]
|
||||||
|
t = left.get("temp")
|
||||||
|
if isinstance(t, int):
|
||||||
|
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
|
||||||
|
nozzle_temp_left = float(t & 0xFFFF)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
sequence_id=str(print_data.get("sequence_id", "")),
|
sequence_id=str(print_data.get("sequence_id", "")),
|
||||||
@@ -438,6 +459,13 @@ class PrinterState:
|
|||||||
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
|
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
|
||||||
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
||||||
nozzle_type=print_data.get("nozzle_type", ""),
|
nozzle_type=print_data.get("nozzle_type", ""),
|
||||||
|
nozzle_temp_left=nozzle_temp_left,
|
||||||
|
nozzle_target_temp_left=nozzle_target_temp_left,
|
||||||
|
# Diameter/type per side: H2C currently uses uniform nozzles, so reuse top-level
|
||||||
|
# values. If a future probe shows per-side diameter/type variance, plumb it from
|
||||||
|
# `device.nozzle.info[]` cross-referenced against `device.extruder.info[i].id`.
|
||||||
|
nozzle_diameter_left=float(print_data.get("nozzle_diameter", 0.4)) if nozzle_temp_left is not None else None,
|
||||||
|
nozzle_type_left=print_data.get("nozzle_type", "") if nozzle_temp_left is not None else None,
|
||||||
home_flag=int(print_data.get("home_flag", 0)),
|
home_flag=int(print_data.get("home_flag", 0)),
|
||||||
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
||||||
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
||||||
@@ -473,6 +501,14 @@ class PrinterState:
|
|||||||
"chamber_temp": round(self.chamber_temp, 2),
|
"chamber_temp": round(self.chamber_temp, 2),
|
||||||
"nozzle_diameter": self.nozzle_diameter,
|
"nozzle_diameter": self.nozzle_diameter,
|
||||||
"nozzle_type": self.nozzle_type,
|
"nozzle_type": self.nozzle_type,
|
||||||
|
"nozzle_temp_left": (
|
||||||
|
round(self.nozzle_temp_left, 2) if self.nozzle_temp_left is not None else None
|
||||||
|
),
|
||||||
|
"nozzle_target_temp_left": (
|
||||||
|
round(self.nozzle_target_temp_left, 2) if self.nozzle_target_temp_left is not None else None
|
||||||
|
),
|
||||||
|
"nozzle_diameter_left": self.nozzle_diameter_left,
|
||||||
|
"nozzle_type_left": self.nozzle_type_left,
|
||||||
"gcode_state": self.gcode_state,
|
"gcode_state": self.gcode_state,
|
||||||
"print_type": self.print_type,
|
"print_type": self.print_type,
|
||||||
"print_percent": self.print_percent,
|
"print_percent": self.print_percent,
|
||||||
@@ -482,6 +518,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,
|
||||||
@@ -513,8 +551,19 @@ class PrinterState:
|
|||||||
snapshot["tray_now"] = self.ams.tray_now
|
snapshot["tray_now"] = self.ams.tray_now
|
||||||
snapshot["ams_version"] = self.ams.version
|
snapshot["ams_version"] = self.ams.version
|
||||||
|
|
||||||
|
from .models import ams_type_from_info
|
||||||
|
|
||||||
filaments = []
|
filaments = []
|
||||||
for unit in self.ams.units:
|
for unit in self.ams.units:
|
||||||
|
# `unit_id` is the AMS unit's own id from the MQTT payload — for the
|
||||||
|
# original AMS / AMS 2 Pro it's a small int (0,1,2,...); for AMS HT
|
||||||
|
# it has the 0x80 bit set (e.g. 128). Don't compute tray_id // 4 —
|
||||||
|
# multi-AMS-type setups are not contiguous.
|
||||||
|
try:
|
||||||
|
unit_id_int = int(unit.unit_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
unit_id_int = None
|
||||||
|
ams_type_label = ams_type_from_info(unit.info)
|
||||||
for tray in unit.trays:
|
for tray in unit.trays:
|
||||||
if tray.tray_type:
|
if tray.tray_type:
|
||||||
filaments.append({
|
filaments.append({
|
||||||
@@ -540,6 +589,9 @@ class PrinterState:
|
|||||||
"tray_bed_temp": tray.tray_bed_temp,
|
"tray_bed_temp": tray.tray_bed_temp,
|
||||||
"bed_temp_type": tray.bed_temp_type,
|
"bed_temp_type": tray.bed_temp_type,
|
||||||
"cols": tray.cols,
|
"cols": tray.cols,
|
||||||
|
"ams_unit_id": unit_id_int,
|
||||||
|
"ams_info": unit.info,
|
||||||
|
"ams_type": ams_type_label,
|
||||||
})
|
})
|
||||||
snapshot["filaments"] = filaments
|
snapshot["filaments"] = filaments
|
||||||
|
|
||||||
@@ -550,6 +602,7 @@ class PrinterState:
|
|||||||
"ams_id": unit.ams_id,
|
"ams_id": unit.ams_id,
|
||||||
"chip_id": unit.chip_id,
|
"chip_id": unit.chip_id,
|
||||||
"info": unit.info,
|
"info": unit.info,
|
||||||
|
"ams_type": ams_type_from_info(unit.info),
|
||||||
"humidity": unit.humidity,
|
"humidity": unit.humidity,
|
||||||
"humidity_raw": unit.humidity_raw,
|
"humidity_raw": unit.humidity_raw,
|
||||||
"temp": unit.temp,
|
"temp": unit.temp,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 3D Printer Charts Initialization and Management
|
// 3D Printer Charts Initialization and Management
|
||||||
// Chart.js implementation for printer metrics visualization
|
// Chart.js implementation for printer metrics visualization
|
||||||
|
|
||||||
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
let nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
||||||
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
||||||
|
|
||||||
function showNoDataMessage(canvasId) {
|
function showNoDataMessage(canvasId) {
|
||||||
@@ -75,6 +75,50 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize Left Nozzle Temperature Chart (H2C-class dual-nozzle).
|
||||||
|
// Mounted only when the canvas exists AND the API returned non-null
|
||||||
|
// left-side samples — single-nozzle printers leave the column NULL.
|
||||||
|
const nozzleLeftCanvas = document.getElementById('nozzleTempLeftChart');
|
||||||
|
const hasLeftData = Array.isArray(printerData.nozzle_temp_left)
|
||||||
|
&& printerData.nozzle_temp_left.some(v => v !== null && v !== undefined);
|
||||||
|
if (nozzleLeftCanvas && hasLeftData) {
|
||||||
|
const nozzleLeftCtx = nozzleLeftCanvas.getContext('2d');
|
||||||
|
nozzleTempLeftChart = new Chart(nozzleLeftCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Actual Temp (Left)',
|
||||||
|
data: printerData.nozzle_temp_left,
|
||||||
|
borderColor: 'rgb(54, 162, 235)',
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Target Temp (Left)',
|
||||||
|
data: printerData.nozzle_target_temp_left,
|
||||||
|
borderColor: 'rgb(153, 102, 255)',
|
||||||
|
backgroundColor: 'rgba(153, 102, 255, 0.05)',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||||
|
});
|
||||||
|
} else if (nozzleLeftCanvas) {
|
||||||
|
showNoDataMessage('nozzleTempLeftChart');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Bed Temperature Chart
|
// Initialize Bed Temperature Chart
|
||||||
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
||||||
bedTempChart = new Chart(bedCtx, {
|
bedTempChart = new Chart(bedCtx, {
|
||||||
@@ -702,7 +746,7 @@ function updateChartTheme() {
|
|||||||
|
|
||||||
// Update all charts
|
// Update all charts
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -804,7 +848,7 @@ function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
|
|||||||
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
|
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
|
||||||
|
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,13 @@ function updateAllPrinterCharts(data) {
|
|||||||
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (typeof nozzleTempLeftChart !== 'undefined' && nozzleTempLeftChart) {
|
||||||
|
updateChartData(nozzleTempLeftChart, data.timestamps, [
|
||||||
|
{ data: data.nozzle_temp_left || [], datasetIndex: 0 },
|
||||||
|
{ data: data.nozzle_target_temp_left || [], datasetIndex: 1 }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
updateChartData(bedTempChart, data.timestamps, [
|
updateChartData(bedTempChart, data.timestamps, [
|
||||||
{ data: data.bed_temp, datasetIndex: 0 },
|
{ data: data.bed_temp, datasetIndex: 0 },
|
||||||
{ data: data.bed_target_temp, datasetIndex: 1 }
|
{ data: data.bed_target_temp, datasetIndex: 1 }
|
||||||
@@ -269,7 +276,7 @@ function addProjectMarkersToCharts(markers, timestamps) {
|
|||||||
console.log('Adding project markers:', markers);
|
console.log('Adding project markers:', markers);
|
||||||
|
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -400,7 +407,7 @@ function resetPrinterControls() {
|
|||||||
|
|
||||||
// Clear annotations and reload with original data
|
// Clear annotations and reload with original data
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for usage in print_usages %}
|
{% for usage in print_usages %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ usage.print_job.project_name }}</td>
|
<td>{{ usage.print_job.display_name }}</td>
|
||||||
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
||||||
<td>Tray {{ usage.tray_id }}</td>
|
<td>Tray {{ usage.tray_id }}</td>
|
||||||
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
||||||
|
|||||||
@@ -70,14 +70,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<select name="loaded" class="form-select">
|
<select name="loaded" class="form-select">
|
||||||
<option value="">All Spools</option>
|
<option value="">All Spools</option>
|
||||||
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
||||||
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
|
<select name="ams_type" class="form-select">
|
||||||
|
<option value="">All AMS Types</option>
|
||||||
|
{% for at in ams_type_choices %}
|
||||||
|
<option value="{{ at }}" {% if request.GET.ams_type == at %}selected{% endif %}>{{ at }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
<button type="submit" class="btn btn-secondary">Filter</button>
|
<button type="submit" class="btn btn-secondary">Filter</button>
|
||||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +157,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if filament.is_loaded_in_ams %}
|
{% if filament.is_loaded_in_ams %}
|
||||||
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span>
|
<span class="badge bg-success">
|
||||||
|
{% if filament.ams_type %}{{ filament.ams_type }}{% else %}AMS{% endif %}
|
||||||
|
{% if filament.ams_unit_id is not None %}#{{ filament.ams_unit_id }}{% endif %}
|
||||||
|
· Tray {{ filament.current_tray_id }}
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Storage</span>
|
<span class="badge bg-secondary">Storage</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -22,7 +22,41 @@
|
|||||||
|
|
||||||
<!-- Summary Cards Row -->
|
<!-- Summary Cards Row -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<!-- Nozzle Temperature Card -->
|
{% if stats.is_dual_nozzle %}
|
||||||
|
<!-- Right Nozzle (dual-nozzle printers, e.g. H2C) -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card infra-card-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Right Nozzle</div>
|
||||||
|
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}°C</div>
|
||||||
|
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°C
|
||||||
|
{% if stats.nozzle_type %}· {{ stats.nozzle_type }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Left Nozzle -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card infra-card-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Left Nozzle</div>
|
||||||
|
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}°C</div>
|
||||||
|
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°C
|
||||||
|
{% if stats.nozzle_type_left %}· {{ stats.nozzle_type_left }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Nozzle Temperature Card (single-nozzle printers) -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-warning">
|
<div class="card infra-card-warning">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -36,6 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Bed Temperature Card -->
|
<!-- Bed Temperature Card -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
@@ -94,7 +129,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<strong>Job Name:</strong> {{ stats.subtask_name }}
|
<strong>Job Name:</strong> {{ stats.job_display_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<strong>State:</strong> {{ stats.gcode_state }}
|
<strong>State:</strong> {{ stats.gcode_state }}
|
||||||
@@ -266,10 +301,10 @@
|
|||||||
|
|
||||||
<!-- Charts Section -->
|
<!-- Charts Section -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<!-- Nozzle Temperature Chart -->
|
<!-- Nozzle Temperature Chart (right side / single nozzle) -->
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Nozzle Temperature</div>
|
<div class="card-header">{% if stats.is_dual_nozzle %}Right Nozzle Temperature{% else %}Nozzle Temperature{% endif %}</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<canvas id="nozzleTempChart"></canvas>
|
<canvas id="nozzleTempChart"></canvas>
|
||||||
@@ -278,6 +313,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if stats.is_dual_nozzle %}
|
||||||
|
<!-- Left Nozzle Temperature Chart (H2C-class dual-nozzle) -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Left Nozzle Temperature</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="nozzleTempLeftChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Bed Temperature Chart -->
|
<!-- Bed Temperature Chart -->
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
||||||
for m in metrics
|
for m in metrics
|
||||||
],
|
],
|
||||||
|
"nozzle_temp_left": [
|
||||||
|
float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None
|
||||||
|
for m in metrics
|
||||||
|
],
|
||||||
|
"nozzle_target_temp_left": [
|
||||||
|
float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None
|
||||||
|
for m in metrics
|
||||||
|
],
|
||||||
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
||||||
"bed_target_temp": [
|
"bed_target_temp": [
|
||||||
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
||||||
@@ -130,14 +138,41 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
except Exception:
|
except Exception:
|
||||||
filaments_list = []
|
filaments_list = []
|
||||||
|
|
||||||
|
subtask_name = latest_metric.subtask_name or "No active print"
|
||||||
|
# Look up active PrintJob for a better display name (cloud design_title)
|
||||||
|
job_display_name = subtask_name
|
||||||
|
if latest_metric.subtask_name:
|
||||||
|
active_job = (
|
||||||
|
PrintJob.objects.filter(
|
||||||
|
device=printer_device,
|
||||||
|
project_name=latest_metric.subtask_name,
|
||||||
|
end_time__isnull=True,
|
||||||
|
).select_related('cloud_task').first()
|
||||||
|
or PrintJob.objects.filter(
|
||||||
|
device=printer_device,
|
||||||
|
project_name=latest_metric.subtask_name,
|
||||||
|
).select_related('cloud_task').order_by('-start_time').first()
|
||||||
|
)
|
||||||
|
if active_job:
|
||||||
|
job_display_name = active_job.display_name
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
||||||
|
"nozzle_target_temp": float(latest_metric.nozzle_target_temp) if latest_metric.nozzle_target_temp else 0,
|
||||||
|
"nozzle_diameter": float(latest_metric.nozzle_diameter) if latest_metric.nozzle_diameter else None,
|
||||||
|
"nozzle_type": latest_metric.nozzle_type or "",
|
||||||
|
"nozzle_temp_left": float(latest_metric.nozzle_temp_left) if latest_metric.nozzle_temp_left is not None else None,
|
||||||
|
"nozzle_target_temp_left": float(latest_metric.nozzle_target_temp_left) if latest_metric.nozzle_target_temp_left is not None else None,
|
||||||
|
"nozzle_diameter_left": float(latest_metric.nozzle_diameter_left) if latest_metric.nozzle_diameter_left is not None else None,
|
||||||
|
"nozzle_type_left": latest_metric.nozzle_type_left or "",
|
||||||
|
"is_dual_nozzle": latest_metric.nozzle_temp_left is not None,
|
||||||
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
||||||
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
||||||
"print_percent": latest_metric.print_percent or 0,
|
"print_percent": latest_metric.print_percent or 0,
|
||||||
"gcode_state": latest_metric.gcode_state or "Unknown",
|
"gcode_state": latest_metric.gcode_state or "Unknown",
|
||||||
"print_type": latest_metric.print_type or "idle",
|
"print_type": latest_metric.print_type or "idle",
|
||||||
"subtask_name": latest_metric.subtask_name or "No active print",
|
"subtask_name": subtask_name,
|
||||||
|
"job_display_name": job_display_name,
|
||||||
"chamber_light": latest_metric.chamber_light or "unknown",
|
"chamber_light": latest_metric.chamber_light or "unknown",
|
||||||
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||||
"ams_humidity": latest_metric.ams_humidity,
|
"ams_humidity": latest_metric.ams_humidity,
|
||||||
@@ -158,7 +193,24 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def _calculate_project_markers(self, metrics, timezone_info):
|
def _calculate_project_markers(self, metrics, timezone_info):
|
||||||
"""Calculate where print jobs start and end"""
|
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
||||||
|
if not metrics:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
||||||
|
window_start = metrics[0].timestamp
|
||||||
|
window_end = metrics[-1].timestamp
|
||||||
|
device = metrics[0].device
|
||||||
|
jobs_qs = PrintJob.objects.filter(
|
||||||
|
device=device,
|
||||||
|
start_time__gte=window_start - timedelta(minutes=5),
|
||||||
|
start_time__lte=window_end + timedelta(minutes=5),
|
||||||
|
).select_related('cloud_task')
|
||||||
|
# Map project_name (= subtask_name) -> best display name
|
||||||
|
subtask_to_display = {}
|
||||||
|
for job in jobs_qs:
|
||||||
|
subtask_to_display[job.project_name] = job.display_name
|
||||||
|
|
||||||
markers = []
|
markers = []
|
||||||
current_job = None
|
current_job = None
|
||||||
last_state = None
|
last_state = None
|
||||||
@@ -170,21 +222,23 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
||||||
|
|
||||||
if subtask and subtask != current_job and is_printing:
|
if subtask and subtask != current_job and is_printing:
|
||||||
|
display = subtask_to_display.get(subtask, subtask)
|
||||||
markers.append({
|
markers.append({
|
||||||
'type': 'start',
|
'type': 'start',
|
||||||
'index': idx,
|
'index': idx,
|
||||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||||
'project_name': subtask,
|
'project_name': display,
|
||||||
})
|
})
|
||||||
current_job = subtask
|
current_job = subtask
|
||||||
last_state = gcode_state
|
last_state = gcode_state
|
||||||
|
|
||||||
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
||||||
|
display = subtask_to_display.get(current_job, current_job)
|
||||||
markers.append({
|
markers.append({
|
||||||
'type': 'end',
|
'type': 'end',
|
||||||
'index': idx,
|
'index': idx,
|
||||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||||
'project_name': current_job,
|
'project_name': display,
|
||||||
})
|
})
|
||||||
current_job = None
|
current_job = None
|
||||||
|
|
||||||
@@ -309,6 +363,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
|||||||
dates = []
|
dates = []
|
||||||
nozzle_temp = []
|
nozzle_temp = []
|
||||||
nozzle_target_temp = []
|
nozzle_target_temp = []
|
||||||
|
nozzle_temp_left = []
|
||||||
|
nozzle_target_temp_left = []
|
||||||
bed_temp = []
|
bed_temp = []
|
||||||
bed_target_temp = []
|
bed_target_temp = []
|
||||||
print_percent = []
|
print_percent = []
|
||||||
@@ -336,6 +392,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
|||||||
dates.append(ts.strftime('%Y-%m-%d'))
|
dates.append(ts.strftime('%Y-%m-%d'))
|
||||||
nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None)
|
nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None)
|
||||||
nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None)
|
nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None)
|
||||||
|
nozzle_temp_left.append(float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None)
|
||||||
|
nozzle_target_temp_left.append(float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None)
|
||||||
bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
|
bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
|
||||||
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
|
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
|
||||||
print_percent.append(m.print_percent if m.print_percent else 0)
|
print_percent.append(m.print_percent if m.print_percent else 0)
|
||||||
@@ -413,6 +471,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
|||||||
"dates": dates,
|
"dates": dates,
|
||||||
"nozzle_temp": nozzle_temp,
|
"nozzle_temp": nozzle_temp,
|
||||||
"nozzle_target_temp": nozzle_target_temp,
|
"nozzle_target_temp": nozzle_target_temp,
|
||||||
|
"nozzle_temp_left": nozzle_temp_left,
|
||||||
|
"nozzle_target_temp_left": nozzle_target_temp_left,
|
||||||
"bed_temp": bed_temp,
|
"bed_temp": bed_temp,
|
||||||
"bed_target_temp": bed_target_temp,
|
"bed_target_temp": bed_target_temp,
|
||||||
"print_percent": print_percent,
|
"print_percent": print_percent,
|
||||||
@@ -523,6 +583,10 @@ class FilamentListView(LoginRequiredMixin, ListView):
|
|||||||
elif loaded == 'no':
|
elif loaded == 'no':
|
||||||
queryset = queryset.filter(is_loaded_in_ams=False)
|
queryset = queryset.filter(is_loaded_in_ams=False)
|
||||||
|
|
||||||
|
ams_type = self.request.GET.get('ams_type')
|
||||||
|
if ams_type:
|
||||||
|
queryset = queryset.filter(ams_type=ams_type)
|
||||||
|
|
||||||
search = self.request.GET.get('search')
|
search = self.request.GET.get('search')
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
@@ -542,6 +606,11 @@ class FilamentListView(LoginRequiredMixin, ListView):
|
|||||||
context['filament_types'] = sorted(
|
context['filament_types'] = sorted(
|
||||||
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
||||||
)
|
)
|
||||||
|
context['ams_type_choices'] = sorted(
|
||||||
|
set(
|
||||||
|
Filament.objects.exclude(ams_type='').values_list('ams_type', flat=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -613,7 +682,7 @@ class FilamentDetailView(LoginRequiredMixin, DetailView):
|
|||||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
filament = self.object
|
filament = self.object
|
||||||
|
|
||||||
context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20]
|
context['print_usages'] = filament.print_usages.select_related('print_job__cloud_task').order_by('-print_job__start_time')[:20]
|
||||||
|
|
||||||
total_consumed = filament.print_usages.aggregate(
|
total_consumed = filament.print_usages.aggregate(
|
||||||
total=Sum('consumed_percent')
|
total=Sum('consumed_percent')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
- "8808:8808"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- bambu_data:/app/data
|
- bambu_data:/app/data
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ autorestart=true
|
|||||||
startretries=10
|
startretries=10
|
||||||
startsecs=5
|
startsecs=5
|
||||||
|
|
||||||
|
[program:mcp_server]
|
||||||
|
command=python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
||||||
|
directory=/app
|
||||||
|
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
autorestart=true
|
||||||
|
startretries=10
|
||||||
|
startsecs=5
|
||||||
|
priority=10
|
||||||
|
|
||||||
[program:migrate]
|
[program:migrate]
|
||||||
command=python standalone/manage.py migrate --noinput
|
command=python standalone/manage.py migrate --noinput
|
||||||
directory=/app
|
directory=/app
|
||||||
|
|||||||
15
native/bambu-run-mcp.service
Normal file
15
native/bambu-run-mcp.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bambu-Run MCP Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
WorkingDirectory={{REPO_DIR}}
|
||||||
|
EnvironmentFile={{REPO_DIR}}/.env
|
||||||
|
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
|
||||||
|
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -8,6 +8,12 @@ VENV_DIR="$REPO_DIR/.venv"
|
|||||||
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
||||||
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
||||||
|
|
||||||
|
# Include MCP service if installed
|
||||||
|
SERVICE_DIR="$HOME/.config/systemd/user"
|
||||||
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
|
SERVICES="$SERVICES bambu-run-mcp.service"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-help}" in
|
case "${1:-help}" in
|
||||||
start)
|
start)
|
||||||
systemctl --user start $SERVICES
|
systemctl --user start $SERVICES
|
||||||
@@ -25,14 +31,22 @@ case "${1:-help}" in
|
|||||||
systemctl --user status $SERVICES --no-pager
|
systemctl --user status $SERVICES --no-pager
|
||||||
;;
|
;;
|
||||||
logs)
|
logs)
|
||||||
journalctl --user -u bambu-run-web -u bambu-run-collector -f --no-hostname
|
JOURNAL_UNITS="-u bambu-run-web -u bambu-run-collector"
|
||||||
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
|
JOURNAL_UNITS="$JOURNAL_UNITS -u bambu-run-mcp"
|
||||||
|
fi
|
||||||
|
journalctl --user $JOURNAL_UNITS -f --no-hostname
|
||||||
;;
|
;;
|
||||||
update)
|
update)
|
||||||
echo "Pulling latest code..."
|
echo "Pulling latest code..."
|
||||||
cd "$REPO_DIR" && git pull
|
cd "$REPO_DIR" && git pull
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
|
EXTRAS="standalone"
|
||||||
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
|
EXTRAS="standalone,mcp"
|
||||||
|
fi
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet ".[$EXTRAS]"
|
||||||
|
|
||||||
echo "Running migrations..."
|
echo "Running migrations..."
|
||||||
$MANAGE migrate --noinput
|
$MANAGE migrate --noinput
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bambu-run"
|
name = "bambu-run"
|
||||||
version = "0.1.3"
|
version = "0.1.7"
|
||||||
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.9"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Runnan Li"},
|
{name = "Runnan Li"},
|
||||||
]
|
]
|
||||||
@@ -40,6 +40,9 @@ standalone = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
]
|
]
|
||||||
|
mcp = [
|
||||||
|
"mcp[cli]>=1.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff",
|
"ruff",
|
||||||
"pytest",
|
"pytest",
|
||||||
|
|||||||
27
setup.sh
27
setup.sh
@@ -201,6 +201,25 @@ sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
|||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable bambu-run-web.service bambu-run-collector.service
|
systemctl --user enable bambu-run-web.service bambu-run-collector.service
|
||||||
|
|
||||||
|
# ── 9b. Optional MCP server ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo
|
||||||
|
MCP_ENABLED=false
|
||||||
|
read -rp "Enable MCP server for AI agent access (Claude Desktop, Claude Code, etc.)? [y/N] " ENABLE_MCP
|
||||||
|
if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then
|
||||||
|
green "Installing MCP dependencies..."
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet ".[mcp]"
|
||||||
|
|
||||||
|
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
||||||
|
"$REPO_DIR/native/bambu-run-mcp.service" > "$SERVICE_DIR/bambu-run-mcp.service"
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable bambu-run-mcp.service
|
||||||
|
systemctl --user start bambu-run-mcp.service
|
||||||
|
MCP_ENABLED=true
|
||||||
|
green "MCP server enabled on port 8808."
|
||||||
|
fi
|
||||||
|
|
||||||
# Enable linger so services survive SSH logout
|
# Enable linger so services survive SSH logout
|
||||||
loginctl enable-linger "$USER" 2>/dev/null || \
|
loginctl enable-linger "$USER" 2>/dev/null || \
|
||||||
sudo loginctl enable-linger "$USER" 2>/dev/null || \
|
sudo loginctl enable-linger "$USER" 2>/dev/null || \
|
||||||
@@ -255,9 +274,17 @@ green " Bambu-Run is running!"
|
|||||||
green "============================================"
|
green "============================================"
|
||||||
echo
|
echo
|
||||||
echo " Dashboard: $DASHBOARD_URL"
|
echo " Dashboard: $DASHBOARD_URL"
|
||||||
|
if [ "$MCP_ENABLED" = true ]; then
|
||||||
|
echo " MCP Server: http://${PI_IP:-localhost}:8808/sse"
|
||||||
|
fi
|
||||||
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
|
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
|
||||||
echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f"
|
echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f"
|
||||||
echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
|
echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
|
||||||
echo
|
echo
|
||||||
|
if [ "$MCP_ENABLED" = true ]; then
|
||||||
|
echo " Claude Desktop config:"
|
||||||
|
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
echo " Services auto-start on boot. Safe to close SSH."
|
echo " Services auto-start on boot. Safe to close SSH."
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ PRINTER_IP = os.environ.get("PRINTER_IP", "")
|
|||||||
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
|
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
|
||||||
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
|
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user